diff --git a/README.md b/README.md index 50ba78da..3a2e79bf 100644 --- a/README.md +++ b/README.md @@ -1 +1,29 @@ # NBE3-4-1-Team8 + +![Image](https://github.com/user-attachments/assets/e668b7d4-3324-42f8-9aa6-9877f6e1bcef) + +![Image](https://github.com/user-attachments/assets/9467444d-f04a-46a6-8c02-f8af68652acf) + +![Image](https://github.com/user-attachments/assets/033cc6d2-98a8-4188-b077-55f5b79beb86) + +![Image](https://github.com/user-attachments/assets/d0095246-bf8b-4d4f-a1ca-0df13e72e3f0) + +![Image](https://github.com/user-attachments/assets/b53a9f5a-e231-4c48-bedd-51adeb4ca789) + +![Image](https://github.com/user-attachments/assets/c738c3d4-5976-4a4e-8e1f-1e17eb3bec6a) + +![Image](https://github.com/user-attachments/assets/fdea1b9e-d353-4290-866b-9947618e5802) + +![Image](https://github.com/user-attachments/assets/8c7630e8-ad22-4969-b44d-c8f6057c00c4) + +![Image](https://github.com/user-attachments/assets/fec4793f-be3b-4f58-a024-0f07b80fb9d1) + +![Image](https://github.com/user-attachments/assets/8ce8fc10-a449-48c6-af82-363bf1ab6566) + +![Image](https://github.com/user-attachments/assets/8afaa409-32bb-4943-8009-b9c52052a410) + +![Image](https://github.com/user-attachments/assets/26e703ba-e9df-43ee-8f76-36fdfbfa8ea3) + +![Image](https://github.com/user-attachments/assets/a1183c99-7865-46e3-9b85-f08595bc9bca) + +![Image](https://github.com/user-attachments/assets/e1ec3010-b480-4d13-90a3-d06bb1a7fe7a) diff --git a/backend/.gitignore b/backend/.gitignore index c2065bc2..4b731461 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -4,6 +4,14 @@ build/ !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ !**/src/test/**/build/ +.DS_Store +.idea/ +**/.idea +**/.DS_Store +**/main/resources/application.yml +**/test/resources/application.yml +../.idea +*.json ### STS ### .apt_generated @@ -16,9 +24,9 @@ build/ bin/ !**/src/main/**/bin/ !**/src/test/**/bin/ +../.DS_Store ### IntelliJ IDEA ### -.idea *.iws *.iml *.ipr diff --git a/backend/build.gradle b/backend/build.gradle index 14db27ad..332e8857 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -33,12 +33,21 @@ dependencies { developmentOnly 'org.springframework.boot:spring-boot-devtools' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' - implementation 'io.jsonwebtoken:jjwt-api:0.12.3' - implementation 'io.jsonwebtoken:jjwt-impl:0.12.3' - implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3' + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + implementation 'io.jsonwebtoken:jjwt-impl:0.11.5' + implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testRuntimeOnly 'com.h2database:h2' + testCompileOnly 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' + + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.1' + + // 메일 템플릿 설정을 위한 thymeleaf + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' } tasks.named('test') { diff --git a/backend/src/main/java/com/example/backend/BackendApplication.java b/backend/src/main/java/com/example/backend/BackendApplication.java index 450efbc3..2a38a539 100644 --- a/backend/src/main/java/com/example/backend/BackendApplication.java +++ b/backend/src/main/java/com/example/backend/BackendApplication.java @@ -2,7 +2,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; +@EnableAsync +@EnableScheduling @SpringBootApplication public class BackendApplication { diff --git a/backend/src/main/java/com/example/backend/domain/cart/controller/CartController.java b/backend/src/main/java/com/example/backend/domain/cart/controller/CartController.java new file mode 100644 index 00000000..b50d7ccd --- /dev/null +++ b/backend/src/main/java/com/example/backend/domain/cart/controller/CartController.java @@ -0,0 +1,72 @@ +package com.example.backend.domain.cart.controller; + +import com.example.backend.domain.cart.dto.CartDeleteForm; +import com.example.backend.domain.cart.dto.CartForm; +import com.example.backend.domain.cart.dto.CartResponse; +import com.example.backend.domain.cart.dto.CartUpdateForm; +import com.example.backend.domain.cart.service.CartService; +import com.example.backend.domain.member.entity.Member; +import com.example.backend.global.auth.model.CustomUserDetails; +import com.example.backend.global.response.GenericResponse; +import com.example.backend.global.validation.ValidationSequence; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/carts") +public class CartController { + private final CartService cartService; + + @PostMapping + public ResponseEntity> addCartItem( + @RequestBody @Validated(ValidationSequence.class) CartForm cartForm, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + + Member member = customUserDetails.getMember(); + + Long cartId = cartService.addCartItem(cartForm, member); + + return ResponseEntity.status(HttpStatus.CREATED).body(GenericResponse.of(cartId)); + } + + @GetMapping + public ResponseEntity>> getCart( + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + + Member member = customUserDetails.getMember(); + + List cartResponses = cartService.getCartByMember(member); + + return ResponseEntity.status(HttpStatus.OK).body(GenericResponse.of(cartResponses)); + } + + @PatchMapping + public ResponseEntity> updateCartItemQuantity( + @RequestBody @Validated(ValidationSequence.class) CartUpdateForm cartUpdateForm, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + + Member member = customUserDetails.getMember(); + Long cartId = cartService.updateCartItemQuantity(cartUpdateForm, member); + + return ResponseEntity.status(HttpStatus.OK).body(GenericResponse.of(cartId)); + } + + @DeleteMapping + public ResponseEntity> deleteCartItem( + @RequestBody @Validated(ValidationSequence.class) CartDeleteForm cartDeleteForm, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + + Member member = customUserDetails.getMember(); + Long deleteProductId = cartService.deleteCartItem(cartDeleteForm, member); + + return ResponseEntity.status(HttpStatus.OK).body(GenericResponse.of(deleteProductId)); + } + +} diff --git a/backend/src/main/java/com/example/backend/domain/cart/converter/CartConverter.java b/backend/src/main/java/com/example/backend/domain/cart/converter/CartConverter.java new file mode 100644 index 00000000..3c13c25c --- /dev/null +++ b/backend/src/main/java/com/example/backend/domain/cart/converter/CartConverter.java @@ -0,0 +1,61 @@ +package com.example.backend.domain.cart.converter; + +import com.example.backend.domain.cart.dto.CartForm; +import com.example.backend.domain.cart.dto.CartResponse; +import com.example.backend.domain.cart.entity.Cart; +import com.example.backend.domain.member.entity.Member; +import com.example.backend.domain.product.entity.Product; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.util.List; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class CartConverter { + + /** + * CartForm -> Cart 엔티티 변환 + * + * @param cartForm 요청 DTO + * @param member 회원 엔티티 + * @param product 상품 엔티티 + * @return Cart 엔티티 + */ + public static Cart from(CartForm cartForm, Member member, Product product) { + return Cart.builder() + .member(member) + .product(product) + .quantity(cartForm.quantity()) + .build(); + } + + /** + * Cart 엔티티 -> CartResponse 변환 + * + * @param cart Cart 엔티티 + * @return 응답 DTO + */ + public static CartResponse toResponse(Cart cart) { + return CartResponse.builder() + .id(cart.getId()) + .productId(cart.getProduct().getId()) + .productName(cart.getProduct().getName()) + .quantity(cart.getQuantity()) + .productPrice(cart.getProduct().getPrice()) + .totalPrice(cart.getProduct().getPrice() * cart.getQuantity()) + .productImgUrl(cart.getProduct().getImgUrl()) + .build(); + } + + /** + * Cart 엔티티 리스트 -> CartResponse 리스트 변환 + * + * @param cartList Cart 엔티티 리스트 + * @return 응답 DTO 리스트 + */ + public static List toResponseList(List cartList) { + return cartList.stream() + .map(CartConverter::toResponse) + .toList(); + } +} diff --git a/backend/src/main/java/com/example/backend/domain/cart/dto/CartDeleteForm.java b/backend/src/main/java/com/example/backend/domain/cart/dto/CartDeleteForm.java new file mode 100644 index 00000000..a6e54923 --- /dev/null +++ b/backend/src/main/java/com/example/backend/domain/cart/dto/CartDeleteForm.java @@ -0,0 +1,9 @@ +package com.example.backend.domain.cart.dto; + +import com.example.backend.global.validation.ValidationSequence; +import jakarta.validation.constraints.NotNull; + +public record CartDeleteForm( + @NotNull(message = "상품 ID는 필수 값입니다.", groups = ValidationSequence.class) + Long productId +) { } diff --git a/backend/src/main/java/com/example/backend/domain/cart/dto/CartForm.java b/backend/src/main/java/com/example/backend/domain/cart/dto/CartForm.java new file mode 100644 index 00000000..0212b498 --- /dev/null +++ b/backend/src/main/java/com/example/backend/domain/cart/dto/CartForm.java @@ -0,0 +1,13 @@ +package com.example.backend.domain.cart.dto; + +import com.example.backend.global.validation.ValidationSequence; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +public record CartForm( + @NotNull(message = "상품 ID는 필수 값입니다.", groups = ValidationSequence.class) + Long productId, + @Min(value = 1, message = "상품 수량은 최소 1개 이상이어야 합니다.", groups = ValidationSequence.class) + int quantity +) { +} \ No newline at end of file diff --git a/backend/src/main/java/com/example/backend/domain/cart/dto/CartResponse.java b/backend/src/main/java/com/example/backend/domain/cart/dto/CartResponse.java new file mode 100644 index 00000000..c573689c --- /dev/null +++ b/backend/src/main/java/com/example/backend/domain/cart/dto/CartResponse.java @@ -0,0 +1,14 @@ +package com.example.backend.domain.cart.dto; + +import lombok.Builder; + +@Builder +public record CartResponse( + Long id, + Long productId, + String productName, + int quantity, + int productPrice, + int totalPrice, + String productImgUrl +) {} diff --git a/backend/src/main/java/com/example/backend/domain/cart/dto/CartUpdateForm.java b/backend/src/main/java/com/example/backend/domain/cart/dto/CartUpdateForm.java new file mode 100644 index 00000000..d37fe431 --- /dev/null +++ b/backend/src/main/java/com/example/backend/domain/cart/dto/CartUpdateForm.java @@ -0,0 +1,12 @@ +package com.example.backend.domain.cart.dto; + +import com.example.backend.global.validation.ValidationSequence; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +public record CartUpdateForm( + @NotNull(message = "상품 ID는 필수 값입니다.", groups = ValidationSequence.class) + Long productId, + @Min(value = 1, message = "상품 수량은 최소 1개 이상이어야 합니다.", groups = ValidationSequence.class) + int quantity +) {} diff --git a/backend/src/main/java/com/example/backend/domain/cart/entity/Cart.java b/backend/src/main/java/com/example/backend/domain/cart/entity/Cart.java new file mode 100644 index 00000000..3bc699a9 --- /dev/null +++ b/backend/src/main/java/com/example/backend/domain/cart/entity/Cart.java @@ -0,0 +1,38 @@ +package com.example.backend.domain.cart.entity; + +import com.example.backend.domain.member.entity.Member; +import com.example.backend.domain.product.entity.Product; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Cart { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "product_id") + private Product product; + + private int quantity; + + @Builder + public Cart(Long id, Member member, Product product, int quantity) { + this.id = id; + this.member = member; + this.product = product; + this.quantity = quantity; + } + + public void updateQuantity(int quantity) { + this.quantity = quantity; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/example/backend/domain/cart/exception/CartErrorCode.java b/backend/src/main/java/com/example/backend/domain/cart/exception/CartErrorCode.java new file mode 100644 index 00000000..0aa23c04 --- /dev/null +++ b/backend/src/main/java/com/example/backend/domain/cart/exception/CartErrorCode.java @@ -0,0 +1,18 @@ +package com.example.backend.domain.cart.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@AllArgsConstructor +@Getter +public enum CartErrorCode { + ALREADY_EXISTS_IN_CART(HttpStatus.BAD_REQUEST, "이미 장바구니에 추가된 상품입니다.", "400-1"), + INVALID_QUANTITY(HttpStatus.BAD_REQUEST,"상품을 최소 1개 이상 추가하여야 합니다." ,"400-2"), + SAME_QUANTITY_IN_CART(HttpStatus.BAD_REQUEST, "현재 수량과 동일한 수량입니다.", "400-3"), + PRODUCT_NOT_FOUND_IN_CART(HttpStatus.NOT_FOUND, "장바구니에 존재하지 않는 상품입니다.", "404-1"); + + final HttpStatus httpStatus; + final String message; + final String code; +} \ No newline at end of file diff --git a/backend/src/main/java/com/example/backend/domain/cart/exception/CartException.java b/backend/src/main/java/com/example/backend/domain/cart/exception/CartException.java new file mode 100644 index 00000000..6d247f4f --- /dev/null +++ b/backend/src/main/java/com/example/backend/domain/cart/exception/CartException.java @@ -0,0 +1,20 @@ +package com.example.backend.domain.cart.exception; + +import org.springframework.http.HttpStatus; + +public class CartException extends RuntimeException { + private final CartErrorCode cartErrorCode; + + public CartException(CartErrorCode cartErrorCode) { + super(cartErrorCode.message); + this.cartErrorCode = cartErrorCode; + } + + public HttpStatus getStatus() { + return cartErrorCode.httpStatus; + } + + public String getCode() { + return cartErrorCode.code; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/example/backend/domain/cart/repository/CartRepository.java b/backend/src/main/java/com/example/backend/domain/cart/repository/CartRepository.java new file mode 100644 index 00000000..a6daec95 --- /dev/null +++ b/backend/src/main/java/com/example/backend/domain/cart/repository/CartRepository.java @@ -0,0 +1,23 @@ +package com.example.backend.domain.cart.repository; + +import com.example.backend.domain.cart.entity.Cart; +import com.example.backend.domain.member.entity.Member; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface CartRepository extends JpaRepository { + + boolean existsByProductIdAndMemberId(Long productId, Long memberId); + + @Query("SELECT c FROM Cart c JOIN FETCH c.product WHERE c.member = :member") + List findAllByMemberWithProducts(@Param("member") Member member); + + void deleteByMemberId(Long memberId); + + @Query("SELECT c FROM Cart c JOIN FETCH c.member WHERE c.product.id = :productId AND c.member.id = :memberId") + Optional findByProductIdAndMemberId(@Param("productId") Long productId, @Param("memberId") Long memberId); +} diff --git a/backend/src/main/java/com/example/backend/domain/cart/service/CartService.java b/backend/src/main/java/com/example/backend/domain/cart/service/CartService.java new file mode 100644 index 00000000..5ba237b9 --- /dev/null +++ b/backend/src/main/java/com/example/backend/domain/cart/service/CartService.java @@ -0,0 +1,86 @@ +package com.example.backend.domain.cart.service; + +import com.example.backend.domain.cart.converter.CartConverter; +import com.example.backend.domain.cart.dto.CartDeleteForm; +import com.example.backend.domain.cart.dto.CartForm; +import com.example.backend.domain.cart.dto.CartResponse; +import com.example.backend.domain.cart.dto.CartUpdateForm; +import com.example.backend.domain.cart.entity.Cart; +import com.example.backend.domain.cart.exception.CartErrorCode; +import com.example.backend.domain.cart.exception.CartException; +import com.example.backend.domain.cart.repository.CartRepository; +import com.example.backend.domain.member.entity.Member; +import com.example.backend.domain.product.service.ProductService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class CartService { + + private final CartRepository cartRepository; + private final ProductService productService; + + @Transactional + public Long addCartItem(CartForm cartForm, Member member) { + // 요청한 상품 ID로 상품 조회 + Long productId = cartForm.productId(); + + // 이미 장바구니에 있는 상품인 경우 exception 발생 + if (cartRepository.existsByProductIdAndMemberId(productId, member.getId())) { + throw new CartException(CartErrorCode.ALREADY_EXISTS_IN_CART); + } + + // 장바구니에 상품 추가 + Cart cart = CartConverter.from( + cartForm, + member, + productService.findById(productId) + ); + + return cartRepository.save(cart).getId(); + } + + @Transactional(readOnly = true) + public List getCartByMember(Member member) { + List cartList = cartRepository.findAllByMemberWithProducts(member); + + return CartConverter.toResponseList(cartList); + } + + @Transactional + public void deleteByMemberId(Long memberId) { + cartRepository.deleteByMemberId(memberId); + } + + @Transactional + public Long updateCartItemQuantity(CartUpdateForm cartUpdateForm, Member member) { + // 해당 상품이 장바구니에 있는지 조회 후 없으면 exception 발생 + Cart cart = cartRepository.findByProductIdAndMemberId(cartUpdateForm.productId(), member.getId()) + .orElseThrow(() -> new CartException(CartErrorCode.PRODUCT_NOT_FOUND_IN_CART)); + + // 현재 수량과 동일한 수량인 경우 exception 발생 + if (cart.getQuantity() == cartUpdateForm.quantity()) { + throw new CartException(CartErrorCode.SAME_QUANTITY_IN_CART); + } + + // 수량 업데이트 + cart.updateQuantity(cartUpdateForm.quantity()); + + return cart.getId(); + } + + @Transactional + public Long deleteCartItem(CartDeleteForm cartDeleteForm, Member member) { + // 해당 상품이 장바구니에 있는지 조회 후 없으면 exception 발생 + Cart cart = cartRepository.findByProductIdAndMemberId(cartDeleteForm.productId(), member.getId()) + .orElseThrow(() -> new CartException(CartErrorCode.PRODUCT_NOT_FOUND_IN_CART)); + + cartRepository.delete(cart); + + return cart.getProduct().getId(); + } +} diff --git a/backend/src/main/java/com/example/backend/domain/common/Address.java b/backend/src/main/java/com/example/backend/domain/common/Address.java new file mode 100644 index 00000000..bd0c351d --- /dev/null +++ b/backend/src/main/java/com/example/backend/domain/common/Address.java @@ -0,0 +1,38 @@ +package com.example.backend.domain.common; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * Address + *

주소지 정보를 관리하는 클래스 입니다.

+ * @author Kim Dong O + */ +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Address { + @Column(nullable = false) + private String city; //시 + + @Column(nullable = false) + private String district; //구 + + @Column(nullable = false) + private String country; //도로명 주소 + + @Column(nullable = false) + private String detail; //상세 주소 + + @Builder + public Address(String city, String district, String country, String detail) { + this.city = city; + this.district = district; + this.country = country; + this.detail = detail; + } +} diff --git a/backend/src/main/java/com/example/backend/domain/common/EmailCertification.java b/backend/src/main/java/com/example/backend/domain/common/EmailCertification.java new file mode 100644 index 00000000..c1e966f4 --- /dev/null +++ b/backend/src/main/java/com/example/backend/domain/common/EmailCertification.java @@ -0,0 +1,42 @@ +package com.example.backend.domain.common; + +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +/** + * EmailCertification + *

인증 코드와 타입, 전송 횟수를 저장하는 객체 입니다.

+ * @author Kim Dong O + */ +@Getter +@NoArgsConstructor +@ToString +public class EmailCertification { + private String certificationCode; + private String verifyType; + private String sendCount; + + @Builder + public EmailCertification(String certificationCode, String verifyType, String sendCount) { + this.certificationCode = certificationCode; + this.verifyType = verifyType; + this.sendCount = sendCount; + } + + public EmailCertification(String certificationCode, String verifyType) { + this.certificationCode = certificationCode; + this.verifyType = verifyType; + this.sendCount = "1"; + } + + public void addResendCount() { + int count = Integer.parseInt(sendCount); + this.sendCount = String.valueOf(count + 1); + } + + public void setCertificationCode(String certificationCode) { + this.certificationCode = certificationCode; + } +} diff --git a/backend/src/main/java/com/example/backend/domain/common/VerifyType.java b/backend/src/main/java/com/example/backend/domain/common/VerifyType.java new file mode 100644 index 00000000..a3a2db21 --- /dev/null +++ b/backend/src/main/java/com/example/backend/domain/common/VerifyType.java @@ -0,0 +1,10 @@ +package com.example.backend.domain.common; + +/** + * VerifyType + *

인증 타입을 정의한 Enum 클래스 입니다.

+ * @author Kim Dong O + */ +public enum VerifyType { + SIGNUP, PASSWORD_RESET; +} diff --git a/backend/src/main/java/com/example/backend/domain/member/controller/MemberController.java b/backend/src/main/java/com/example/backend/domain/member/controller/MemberController.java new file mode 100644 index 00000000..01004995 --- /dev/null +++ b/backend/src/main/java/com/example/backend/domain/member/controller/MemberController.java @@ -0,0 +1,91 @@ +package com.example.backend.domain.member.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +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 com.example.backend.domain.member.conveter.MemberConverter; +import com.example.backend.domain.member.dto.MemberInfoResponse; +import com.example.backend.domain.member.dto.MemberModifyForm; +import com.example.backend.domain.member.dto.MemberSignupForm; +import com.example.backend.domain.member.service.MemberDeleteService; +import com.example.backend.domain.member.dto.PasswordChangeForm; +import com.example.backend.domain.member.service.MemberService; +import com.example.backend.global.auth.model.CustomUserDetails; +import com.example.backend.global.auth.service.CookieService; +import com.example.backend.global.response.GenericResponse; +import com.example.backend.global.validation.ValidationSequence; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/members") +@Tag(name = "ApiV1MemberController", description = "API 회원 컨트롤러") +public class MemberController { + private final MemberService memberService; + private final MemberDeleteService memberDeleteService; + private final CookieService cookieService; + + @Operation(summary = "회원 가입") + @PostMapping("/join") + public ResponseEntity> signUp( + @RequestBody @Validated(ValidationSequence.class) MemberSignupForm memberSignupForm) { + + memberService.signup(memberSignupForm.username(), memberSignupForm.nickname(), + memberSignupForm.password(), memberSignupForm.city(), + memberSignupForm.district(), memberSignupForm.country(), memberSignupForm.detail()); + + return ResponseEntity.status(HttpStatus.CREATED).body(GenericResponse.of()); + } + + @Operation(summary = "회원 정보 조회") + @GetMapping + public ResponseEntity> getMemberInfo( + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + return ResponseEntity.status(HttpStatus.OK) + .body(GenericResponse.of(MemberConverter.from(customUserDetails.getMember()))); + } + + @Operation(summary = "회원 정보 수정") + @PatchMapping + public ResponseEntity> modify( + @AuthenticationPrincipal CustomUserDetails customUserDetails, + @RequestBody @Validated(ValidationSequence.class) MemberModifyForm memberModifyForm) { + return ResponseEntity.status(HttpStatus.OK) + .body(GenericResponse.of(memberService.modify(customUserDetails.getMember().toModel(), memberModifyForm))); + } + + @Operation(summary = "회원 탈퇴") + @DeleteMapping + public ResponseEntity> delete(@AuthenticationPrincipal CustomUserDetails customUserDetails, + HttpServletResponse response) { + cookieService.deleteAccessTokenFromCookie(response); + cookieService.deleteRefreshTokenFromCookie(response); + memberDeleteService.delete(customUserDetails.getMember().toModel()); + return ResponseEntity.status(HttpStatus.NO_CONTENT).body(GenericResponse.of()); + } + + @Operation(summary = "회원 비밀번호 변경") + @PatchMapping("/password") + public ResponseEntity> changePassword(@Validated(ValidationSequence.class) @RequestBody + PasswordChangeForm passwordChangeForm, @AuthenticationPrincipal CustomUserDetails customUserDetails) { + + memberService.passwordChange(passwordChangeForm.originalPassword(), passwordChangeForm.getPassword(), + customUserDetails.getMember()); + + return ResponseEntity.ok().body(GenericResponse.of()); + } + +} diff --git a/backend/src/main/java/com/example/backend/domain/member/conveter/MemberConverter.java b/backend/src/main/java/com/example/backend/domain/member/conveter/MemberConverter.java new file mode 100644 index 00000000..1532fe66 --- /dev/null +++ b/backend/src/main/java/com/example/backend/domain/member/conveter/MemberConverter.java @@ -0,0 +1,46 @@ +package com.example.backend.domain.member.conveter; + +import com.example.backend.domain.common.Address; +import com.example.backend.domain.member.dto.MemberDto; +import com.example.backend.domain.member.dto.MemberInfoResponse; +import com.example.backend.domain.member.dto.MemberModifyForm; +import com.example.backend.domain.member.entity.Member; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class MemberConverter { + + public static MemberInfoResponse from(Member member){ + return MemberInfoResponse.builder() + .username(member.getUsername()) + .nickname(member.getNickname()) + .role(member.getRole()) + .address(member.getAddress()) + .build(); + } + + public static Member of(MemberDto memberDto, MemberModifyForm memberModifyForm){ + return Member.builder() + .id(memberDto.id()) + .username(memberDto.username()) + .nickname(memberModifyForm.nickname()) + .password(memberDto.password()) + .memberStatus(memberDto.memberStatus()) + .role(memberDto.role()) + .address(toAddress(memberModifyForm)) + .createdAt(memberDto.createdAt()) + .modifiedAt(memberDto.modifiedAt()) + .build(); + } + + private static Address toAddress(MemberModifyForm memberModifyForm) { + return Address.builder() + .city(memberModifyForm.city()) + .district(memberModifyForm.district()) + .country(memberModifyForm.country()) + .detail(memberModifyForm.detail()) + .build(); + } +} diff --git a/backend/src/main/java/com/example/backend/domain/member/dto/MemberDto.java b/backend/src/main/java/com/example/backend/domain/member/dto/MemberDto.java new file mode 100644 index 00000000..d9f63f15 --- /dev/null +++ b/backend/src/main/java/com/example/backend/domain/member/dto/MemberDto.java @@ -0,0 +1,28 @@ +package com.example.backend.domain.member.dto; + +import java.time.ZonedDateTime; + +import com.example.backend.domain.common.Address; +import com.example.backend.domain.member.entity.MemberStatus; +import com.example.backend.domain.member.entity.Role; + +import lombok.Builder; + +/** + * MemberDto + *

Member Dto 입니다.

+ * @param id + * @param username + * @param nickname + * @param password + * @param memberStatus + * @param role + * @param address + * @param createdAt + * @param modifiedAt + * @author Kim Dong O + */ +@Builder +public record MemberDto(Long id, String username, String nickname, String password, MemberStatus memberStatus, Role role, Address address, ZonedDateTime createdAt, + ZonedDateTime modifiedAt) { +} diff --git a/backend/src/main/java/com/example/backend/domain/member/dto/MemberInfoResponse.java b/backend/src/main/java/com/example/backend/domain/member/dto/MemberInfoResponse.java new file mode 100644 index 00000000..868f07f7 --- /dev/null +++ b/backend/src/main/java/com/example/backend/domain/member/dto/MemberInfoResponse.java @@ -0,0 +1,10 @@ +package com.example.backend.domain.member.dto; + +import com.example.backend.domain.common.Address; +import com.example.backend.domain.member.entity.Role; + +import lombok.Builder; + +@Builder +public record MemberInfoResponse (String username, String nickname, Role role, Address address){ +} diff --git a/backend/src/main/java/com/example/backend/domain/member/dto/MemberModifyForm.java b/backend/src/main/java/com/example/backend/domain/member/dto/MemberModifyForm.java new file mode 100644 index 00000000..3819eacd --- /dev/null +++ b/backend/src/main/java/com/example/backend/domain/member/dto/MemberModifyForm.java @@ -0,0 +1,26 @@ +package com.example.backend.domain.member.dto; + +import com.example.backend.global.validation.ValidationGroups; +import com.example.backend.global.validation.annotation.ValidNickname; + +import jakarta.validation.constraints.NotBlank; +import lombok.Builder; + +@Builder +public record MemberModifyForm ( + @ValidNickname(groups = ValidationGroups.PatternGroup.class) + String nickname, + + @NotBlank(message = "도시는 필수 항목 입니다.", groups = ValidationGroups.NotBlankGroup.class) + String city, + + @NotBlank(message = "지역 구는 필수 항목 입니다.", groups = ValidationGroups.NotBlankGroup.class) + String district, + + @NotBlank(message = "도로명 주소는 필수 항목 입니다.", groups = ValidationGroups.NotBlankGroup.class) + String country, + + @NotBlank(message = "상세 주소는 필수 항목 입니다.", groups = ValidationGroups.NotBlankGroup.class) + String detail){ + +} diff --git a/backend/src/main/java/com/example/backend/domain/member/dto/MemberSignupForm.java b/backend/src/main/java/com/example/backend/domain/member/dto/MemberSignupForm.java new file mode 100644 index 00000000..687e4567 --- /dev/null +++ b/backend/src/main/java/com/example/backend/domain/member/dto/MemberSignupForm.java @@ -0,0 +1,64 @@ +package com.example.backend.domain.member.dto; + +import static com.example.backend.global.validation.ValidationGroups.*; + +import com.example.backend.global.validation.annotation.PasswordMatch; +import com.example.backend.global.validation.annotation.ValidNickname; +import com.example.backend.global.validation.annotation.ValidPassword; +import com.example.backend.global.validation.annotation.ValidUsername; +import com.example.backend.global.validation.validator.PasswordMatchable; + +import jakarta.validation.constraints.NotBlank; +import lombok.Builder; + +/** + * MemberForm + *

회원가입시 사용하는 Request 객체 입니다.

+ * @param username 이메일 + * @param nickname 닉네임 + * @param password 비밀번호 + * @param passwordCheck 비밀번호 확인 + * @param city 시 + * @param district 지역 구 + * @param country 도로명 주소 + * @param detail 상세 주소 + * @author Kim Dong O + */ +@Builder +@PasswordMatch(groups = PatternGroup.class) +public record MemberSignupForm( + @ValidUsername(groups = PatternGroup.class) + String username, + + @ValidNickname(groups = PatternGroup.class) + String nickname, + + @ValidPassword(groups = PatternGroup.class) + String password, + + @ValidPassword(groups = PatternGroup.class) + String passwordCheck, + + @NotBlank(message = "도시는 필수 항목 입니다.", groups = NotBlankGroup.class) + String city, + + @NotBlank(message = "지역 구는 필수 항목 입니다.", groups = NotBlankGroup.class) + String district, + + @NotBlank(message = "도로명 주소는 필수 항목 입니다.", groups = NotBlankGroup.class) + String country, + + @NotBlank(message = "상세 주소는 필수 항목 입니다.", groups = NotBlankGroup.class) + String detail) + implements PasswordMatchable { + + @Override + public String getPassword() { + return this.password; + } + + @Override + public String getPasswordCheck() { + return this.passwordCheck; + } +} diff --git a/backend/src/main/java/com/example/backend/domain/member/dto/PasswordChangeForm.java b/backend/src/main/java/com/example/backend/domain/member/dto/PasswordChangeForm.java new file mode 100644 index 00000000..3234d1f1 --- /dev/null +++ b/backend/src/main/java/com/example/backend/domain/member/dto/PasswordChangeForm.java @@ -0,0 +1,29 @@ +package com.example.backend.domain.member.dto; + +import static com.example.backend.global.validation.ValidationGroups.*; + +import com.example.backend.global.validation.annotation.PasswordMatch; +import com.example.backend.global.validation.annotation.ValidPassword; +import com.example.backend.global.validation.validator.PasswordMatchable; + +import lombok.Builder; + +@PasswordMatch(groups = PatternGroup.class) +@Builder +public record PasswordChangeForm( + @ValidPassword(groups = PatternGroup.class) String originalPassword, + + @ValidPassword(groups = PatternGroup.class) String password, + + @ValidPassword(groups = PatternGroup.class) String passwordCheck) implements PasswordMatchable { + + @Override + public String getPassword() { + return this.password; + } + + @Override + public String getPasswordCheck() { + return this.passwordCheck; + } +} diff --git a/backend/src/main/java/com/example/backend/domain/member/entity/Member.java b/backend/src/main/java/com/example/backend/domain/member/entity/Member.java new file mode 100644 index 00000000..dd69d060 --- /dev/null +++ b/backend/src/main/java/com/example/backend/domain/member/entity/Member.java @@ -0,0 +1,125 @@ +package com.example.backend.domain.member.entity; + +import java.time.ZonedDateTime; +import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; + +import com.example.backend.domain.common.Address; +import com.example.backend.domain.member.dto.MemberDto; +import com.example.backend.global.baseEntity.BaseEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +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 lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Member extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String username; + + @Column(nullable = false, unique = true) + private String nickname; + + @Column(nullable = false) + private String password; + + @Enumerated(EnumType.STRING) + @Column(length = 20, nullable = false) + private MemberStatus memberStatus; + + @Enumerated(EnumType.STRING) + @Column(length = 20, nullable = false) + private Role role; + + @Embedded + private Address address; + + @Builder(toBuilder = true) + protected Member(Long id, String username, String nickname, String password, MemberStatus memberStatus, Role role, + Address address, ZonedDateTime createdAt, + ZonedDateTime modifiedAt) { + this.id = id; + this.username = username; + this.nickname = nickname; + this.password = password; + this.memberStatus = memberStatus; + this.role = role; + this.address = address; + this.createdAt = createdAt; + this.modifiedAt = modifiedAt; + } + + public static Member from(MemberDto memberDto) { + return Member.builder() + .id(memberDto.id()) + .username(memberDto.username()) + .nickname(memberDto.nickname()) + .password(memberDto.password()) + .memberStatus(memberDto.memberStatus()) + .role(memberDto.role()) + .address(memberDto.address()) + .createdAt(memberDto.createdAt()) + .modifiedAt(memberDto.modifiedAt()) + .build(); + } + + public MemberDto toModel() { + return MemberDto.builder() + .id(this.id) + .username(this.username) + .nickname(this.nickname) + .password(this.password) + .memberStatus(this.memberStatus) + .role(this.role) + .address(this.address) + .createdAt(this.createdAt) + .modifiedAt(this.modifiedAt) + .build(); + } + + public void changePassword(String password) { + this.password = password; + } + + public void verify() { + this.memberStatus = MemberStatus.ACTIVE; + } + + public static String createTemporaryPassword() { + final String SPECIAL_CHARACTERS = "~!@#$%^&*+=()_-"; + String uuid = UUID.randomUUID().toString().replace("-", ""); + StringBuilder password = new StringBuilder(); + + char randomLetter = (char)('a' + ThreadLocalRandom.current().nextInt(26)); + password.append(randomLetter); + + char randomSpecialChar = SPECIAL_CHARACTERS.charAt( + ThreadLocalRandom.current().nextInt(SPECIAL_CHARACTERS.length())); + password.append(randomSpecialChar); + + char randomDigit = (char)('0' + ThreadLocalRandom.current().nextInt(10)); + password.append(randomDigit); + + while (password.length() < 8) { + char randomChar = uuid.charAt(ThreadLocalRandom.current().nextInt(uuid.length())); + password.append(randomChar); + } + + return password.toString(); + } +} diff --git a/backend/src/main/java/com/example/backend/domain/member/entity/MemberStatus.java b/backend/src/main/java/com/example/backend/domain/member/entity/MemberStatus.java new file mode 100644 index 00000000..a7857196 --- /dev/null +++ b/backend/src/main/java/com/example/backend/domain/member/entity/MemberStatus.java @@ -0,0 +1,11 @@ +package com.example.backend.domain.member.entity; + +/** + * 멤버 상태를 나타내는 Enum 입니다, + *

PENDING -> 이메일 인증 X
+ * ACTIVE -> 이메일 인증 완료

+ * @author Kim Dong O + */ +public enum MemberStatus { + PENDING, ACTIVE +} diff --git a/backend/src/main/java/com/example/backend/domain/member/entity/Role.java b/backend/src/main/java/com/example/backend/domain/member/entity/Role.java new file mode 100644 index 00000000..9cefc302 --- /dev/null +++ b/backend/src/main/java/com/example/backend/domain/member/entity/Role.java @@ -0,0 +1,10 @@ +package com.example.backend.domain.member.entity; + +/** + * Role + *

회원의 권한을 정의한 Enum 클래스 입니다.

+ * @author Kim Dong O + */ +public enum Role { + ROLE_USER, ROLE_ADMIN +} diff --git a/backend/src/main/java/com/example/backend/domain/member/exception/MemberErrorCode.java b/backend/src/main/java/com/example/backend/domain/member/exception/MemberErrorCode.java new file mode 100644 index 00000000..adeb29eb --- /dev/null +++ b/backend/src/main/java/com/example/backend/domain/member/exception/MemberErrorCode.java @@ -0,0 +1,30 @@ +package com.example.backend.domain.member.exception; + +import org.springframework.http.HttpStatus; + +import lombok.Getter; + +/** + * GlobalErrorCode + *

Global 예외 발생시 예외 코드를 정의하는 Enum 클래스 입니다.

+ * + * @author Kim Dong O + */ +@Getter +public enum MemberErrorCode { + EXISTS_USERNAME(HttpStatus.BAD_REQUEST, "400-1", "중복된 이메일 입니다."), + EXISTS_NICKNAME(HttpStatus.BAD_REQUEST, "400-2", "중복된 닉네임 입니다."), + PASSWORD_NOT_MATCH(HttpStatus.BAD_REQUEST, "400-3", "비밀번호가 일치하지 않습니다."), + + MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "404-1", "회원이 존재하지 않습니다."); + + final HttpStatus httpStatus; + final String code; + final String message; + + MemberErrorCode(HttpStatus httpStatus, String code, String message) { + this.httpStatus = httpStatus; + this.code = code; + this.message = message; + } +} diff --git a/backend/src/main/java/com/example/backend/domain/member/exception/MemberException.java b/backend/src/main/java/com/example/backend/domain/member/exception/MemberException.java new file mode 100644 index 00000000..c4fbf72c --- /dev/null +++ b/backend/src/main/java/com/example/backend/domain/member/exception/MemberException.java @@ -0,0 +1,22 @@ +package com.example.backend.domain.member.exception; + +import org.springframework.http.HttpStatus; + + +public class MemberException extends RuntimeException { + + private final MemberErrorCode memberErrorCode; + + public MemberException(MemberErrorCode memberErrorCode) { + super(memberErrorCode.message); + this.memberErrorCode = memberErrorCode; + } + + public HttpStatus getStatus() { + return memberErrorCode.httpStatus; + } + + public String getCode() { + return memberErrorCode.code; + } +} diff --git a/backend/src/main/java/com/example/backend/domain/member/repository/MemberRepository.java b/backend/src/main/java/com/example/backend/domain/member/repository/MemberRepository.java new file mode 100644 index 00000000..1a6dcd47 --- /dev/null +++ b/backend/src/main/java/com/example/backend/domain/member/repository/MemberRepository.java @@ -0,0 +1,36 @@ +package com.example.backend.domain.member.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.example.backend.domain.member.entity.Member; + + +/** + * MemberJpaRepository + *

MemberJpaRepository 입니다.

+ * @author Kim Dong O + */ +public interface MemberRepository extends JpaRepository { + /** + * 회원 Username이 중복인지 체크하는 메서드 + * @param username + * @return Username이 중복이라면 true, 중복이 아니라면 false + */ + boolean existsByUsername(String username); + + /** + * 회원 Nickname이 중복인지 체크하는 메서드 + * @param nickname + * @return Nickname이 중복이라면 true, 중복이 아니라면 false + */ + boolean existsByNickname(String nickname); + + /** + * 회원 Username으로 조회 기능 + * @param username + * @return {@link Optional} + */ + Optional findByUsername(String username); +} diff --git a/backend/src/main/java/com/example/backend/domain/member/service/MemberDeleteService.java b/backend/src/main/java/com/example/backend/domain/member/service/MemberDeleteService.java new file mode 100644 index 00000000..bfe0e836 --- /dev/null +++ b/backend/src/main/java/com/example/backend/domain/member/service/MemberDeleteService.java @@ -0,0 +1,33 @@ +package com.example.backend.domain.member.service; + +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.example.backend.domain.cart.service.CartService; +import com.example.backend.domain.member.dto.MemberDto; +import com.example.backend.domain.member.entity.Member; +import com.example.backend.domain.member.repository.MemberRepository; +import com.example.backend.domain.orders.service.OrdersService; +import com.example.backend.global.redis.service.RedisService; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class MemberDeleteService { + + private final MemberRepository memberRepository; + private final RedisService redisService; + private final CartService cartService; + private final OrdersService ordersService; + + @Transactional + public void delete(MemberDto memberDto) { + redisService.delete(memberDto.username()); + SecurityContextHolder.clearContext(); + cartService.deleteByMemberId(memberDto.id()); + ordersService.deleteByMemberId(memberDto.id()); + memberRepository.delete(Member.from(memberDto)); + } +} diff --git a/backend/src/main/java/com/example/backend/domain/member/service/MemberService.java b/backend/src/main/java/com/example/backend/domain/member/service/MemberService.java new file mode 100644 index 00000000..bf069041 --- /dev/null +++ b/backend/src/main/java/com/example/backend/domain/member/service/MemberService.java @@ -0,0 +1,116 @@ +package com.example.backend.domain.member.service; + +import java.util.Map; +import java.util.UUID; + +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.example.backend.domain.common.Address; +import com.example.backend.domain.common.EmailCertification; +import com.example.backend.domain.common.VerifyType; +import com.example.backend.domain.member.conveter.MemberConverter; +import com.example.backend.domain.member.dto.MemberDto; +import com.example.backend.domain.member.dto.MemberInfoResponse; +import com.example.backend.domain.member.dto.MemberModifyForm; +import com.example.backend.domain.member.entity.Member; +import com.example.backend.domain.member.entity.MemberStatus; +import com.example.backend.domain.member.entity.Role; +import com.example.backend.domain.member.exception.MemberErrorCode; +import com.example.backend.domain.member.exception.MemberException; +import com.example.backend.domain.member.repository.MemberRepository; +import com.example.backend.global.mail.service.MailService; +import com.example.backend.global.mail.util.TemplateName; +import com.example.backend.global.redis.service.RedisService; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional +public class MemberService { + private static final String REDIS_CERTIFICATION_PREFIX = "certification_email:"; + private final MemberRepository memberRepository; + private final RedisService redisService; + private final MailService mailService; + private final PasswordEncoder passwordEncoder; + private final ObjectMapper objectMapper; + + public void signup(String username, String nickname, String password, String city, String district, String country, + String detail) { + existsMember(username, nickname); + + String certificationCode = UUID.randomUUID().toString(); + + EmailCertification emailCertification = EmailCertification.builder() + .sendCount("1") + .certificationCode(certificationCode) + .verifyType(VerifyType.SIGNUP.toString()) + .build(); + + Map convertValue = objectMapper.convertValue(emailCertification, Map.class); + + redisService.setHashDataAll(REDIS_CERTIFICATION_PREFIX + username, convertValue); + redisService.setTimeout(REDIS_CERTIFICATION_PREFIX + username, 10); + + mailService.sendCertificationMail(username, emailCertification, TemplateName.SIGNUP_VERIFY); + + Address saveAddress = Address.builder() + .city(city) + .district(district) + .country(country) + .detail(detail) + .build(); + + MemberDto saveMemberDto = MemberDto.builder() + .username(username) + .nickname(nickname) + .password(passwordEncoder.encode(password)) + .memberStatus(MemberStatus.PENDING) + .role(Role.ROLE_USER) + .address(saveAddress) + .build(); + + memberRepository.save(Member.from(saveMemberDto)).toModel(); + } + + public void passwordChange(String originalPassword, String changePassword, Member loginMember) { + + if (!passwordEncoder.matches(originalPassword, loginMember.getPassword())) { + throw new MemberException(MemberErrorCode.PASSWORD_NOT_MATCH); + } + + loginMember.changePassword(passwordEncoder.encode(changePassword)); + + memberRepository.save(loginMember); + } + + public MemberInfoResponse modify(MemberDto memberDto, MemberModifyForm memberModifyForm) { + if (!memberDto.nickname().equals(memberModifyForm.nickname())) { + existsNickname(memberModifyForm.nickname()); + } + + return MemberConverter.from( + memberRepository.save(MemberConverter.of(memberDto, memberModifyForm))); + } + + private void existsMember(String username, String nickname) { + boolean usernameExists = memberRepository.existsByUsername(username); + existsNickname(nickname); + + if (usernameExists) { + throw new MemberException(MemberErrorCode.EXISTS_USERNAME); + } + } + + + private void existsNickname (String nickname) { + boolean nicknameExists = memberRepository.existsByNickname(nickname); + + if (nicknameExists) { + throw new MemberException(MemberErrorCode.EXISTS_NICKNAME); + } + } +} diff --git a/backend/src/main/java/com/example/backend/domain/orders/controller/OrdersController.java b/backend/src/main/java/com/example/backend/domain/orders/controller/OrdersController.java new file mode 100644 index 00000000..1ec77430 --- /dev/null +++ b/backend/src/main/java/com/example/backend/domain/orders/controller/OrdersController.java @@ -0,0 +1,81 @@ +package com.example.backend.domain.orders.controller; + + +import com.example.backend.domain.member.entity.Member; +import com.example.backend.domain.orders.dto.OrdersForm; +import com.example.backend.domain.orders.dto.OrdersResponse; +import com.example.backend.domain.orders.service.OrdersService; +import com.example.backend.global.auth.model.CustomUserDetails; +import com.example.backend.global.response.GenericResponse; +import com.example.backend.global.validation.ValidationSequence; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/orders") +public class OrdersController { + + private final OrdersService ordersService; + + @GetMapping("/{id}") + public ResponseEntity> findOne( + @PathVariable(name = "id") Long id + ) { + OrdersResponse response = ordersService.findOne(id); + + return ResponseEntity.ok() + .body(GenericResponse.of(response)); + } + + @GetMapping("/current") + public ResponseEntity>> current( + @AuthenticationPrincipal CustomUserDetails customUserDetails + ) { + Long memberId = customUserDetails.getMember().getId(); + List responseList = ordersService.current(memberId); + + return ResponseEntity.ok() + .body(GenericResponse.of(responseList)); + + } + + @PostMapping + public ResponseEntity> create( + @RequestBody @Validated(ValidationSequence.class) OrdersForm ordersForm, + @AuthenticationPrincipal CustomUserDetails customUserDetails + ) { + Member member = customUserDetails.getMember(); + Long orderId = ordersService.create(ordersForm, member); + + return ResponseEntity.ok() + .body(GenericResponse.of(orderId)); + } + + @GetMapping("/history") + public ResponseEntity>> history( + @AuthenticationPrincipal CustomUserDetails customUserDetails + ) { + Long memberId = customUserDetails.getMember().getId(); + List responseList = ordersService.history(memberId); + + return ResponseEntity.ok() + .body(GenericResponse.of(responseList)); + + } + + @PatchMapping("{id}") + public ResponseEntity> cancel( + @PathVariable(name = "id") Long id, + @AuthenticationPrincipal CustomUserDetails CustomUserDetails + ) { + ordersService.cancelById(id); + return ResponseEntity.ok() + .body(GenericResponse.of()); + } +} diff --git a/backend/src/main/java/com/example/backend/domain/orders/converter/OrdersConverter.java b/backend/src/main/java/com/example/backend/domain/orders/converter/OrdersConverter.java new file mode 100644 index 00000000..371aeddb --- /dev/null +++ b/backend/src/main/java/com/example/backend/domain/orders/converter/OrdersConverter.java @@ -0,0 +1,67 @@ +package com.example.backend.domain.orders.converter; + +import com.example.backend.domain.common.Address; +import com.example.backend.domain.member.entity.Member; +import com.example.backend.domain.orders.dto.OrdersForm; +import com.example.backend.domain.orders.dto.OrdersResponse; +import com.example.backend.domain.orders.dto.ProductInfoDto; +import com.example.backend.domain.orders.entity.Orders; +import com.example.backend.domain.productOrders.entity.ProductOrders; + +import java.util.Comparator; +import java.util.List; + +public class OrdersConverter { + + public static Orders of(OrdersForm ordersForm, Member member, List productOrdersList) { + Address address = toAddress(ordersForm); + return Orders.create() + .member(member) + .productOrdersList(productOrdersList) + .address(address) + .build(); + } + + private static Address toAddress(OrdersForm ordersForm) { + return Address.builder() + .city(ordersForm.city()) + .district(ordersForm.district()) + .country(ordersForm.country()) + .detail(ordersForm.detail()) + .build(); + } + + public static List from(List ordersList) { + return ordersList.stream() + .map(OrdersConverter::toResponse) + .sorted(Comparator.comparing(OrdersResponse::modifiedAt).reversed()) + .toList(); + } + + public static OrdersResponse toResponse(Orders orders) { + return OrdersResponse.builder() + .id(orders.getId()) + .products(toProductInfoDtoList(orders)) + .totalPrice(orders.getTotalPrice()) + .status(orders.getDeliveryStatus()) + .createAt(orders.getCreatedAt()) + .modifiedAt(orders.getModifiedAt()) + .build(); + } + + private static List toProductInfoDtoList(Orders orders) { + return orders.getProductOrdersList().stream() + .map(OrdersConverter::toProductInfoDto) + .toList(); + } + + private static ProductInfoDto toProductInfoDto(ProductOrders productOrders) { + return ProductInfoDto.builder() + .id(productOrders.getProduct().getId()) + .name(productOrders.getProduct().getName()) + .price(productOrders.getPrice()) + .imgUrl(productOrders.getProduct().getImgUrl()) + .quantity(productOrders.getQuantity()) + .build(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/example/backend/domain/orders/dto/OrdersForm.java b/backend/src/main/java/com/example/backend/domain/orders/dto/OrdersForm.java new file mode 100644 index 00000000..e1b022b2 --- /dev/null +++ b/backend/src/main/java/com/example/backend/domain/orders/dto/OrdersForm.java @@ -0,0 +1,38 @@ +package com.example.backend.domain.orders.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; + +import java.util.List; + +import static com.example.backend.global.validation.ValidationGroups.*; + +public record OrdersForm( + @NotNull(message = "회원 ID는 필수입니다.", groups = NotNullGroup.class) + Long memberId, + + @NotBlank(message = "도시는 필수입니다.", groups = NotNullGroup.class) + String city, + + @NotBlank(message = "구는 필수입니다.", groups = NotNullGroup.class) + String district, + + @NotBlank(message = "도로명 주소는 필수입니다.", groups = NotNullGroup.class) + String country, + + @NotBlank(message = "상세 주소는 필수입니다.", groups = NotNullGroup.class) + String detail, + + @NotEmpty(message = "상품 주문 리스트는 비어 있을 수 없습니다.", groups = NotEmptyGroup.class) + @Size(min = 1, max = 20, message = "상품 리스트는 1 ~ 20개 사이여야 합니다.", groups = SizeGroup.class) + List<@Valid ProductOrdersRequest> productOrdersRequestList +) { + public record ProductOrdersRequest( + @NotNull(message = "상품 ID는 필수입니다.", groups = NotNullGroup.class) + Long productId, // 상품 ID + + @Min(value = 1, message = "수량은 1 이상이어야 합니다.", groups = MinGroup.class) + @Max(value = 99999999, message = "수량은 9,999,999개 이하여야 합니다.", groups = MaxGroup.class) + int quantity // 수량 + ) {} +} \ No newline at end of file diff --git a/backend/src/main/java/com/example/backend/domain/orders/dto/OrdersResponse.java b/backend/src/main/java/com/example/backend/domain/orders/dto/OrdersResponse.java new file mode 100644 index 00000000..eef923b3 --- /dev/null +++ b/backend/src/main/java/com/example/backend/domain/orders/dto/OrdersResponse.java @@ -0,0 +1,19 @@ +package com.example.backend.domain.orders.dto; + + +import com.example.backend.domain.orders.status.DeliveryStatus; +import lombok.*; + +import java.time.ZonedDateTime; +import java.util.List; + + +@Builder +public record OrdersResponse( + Long id, + List products, + int totalPrice, + DeliveryStatus status, + ZonedDateTime createAt, + ZonedDateTime modifiedAt +) {} \ No newline at end of file diff --git a/backend/src/main/java/com/example/backend/domain/orders/dto/ProductInfoDto.java b/backend/src/main/java/com/example/backend/domain/orders/dto/ProductInfoDto.java new file mode 100644 index 00000000..d4c419e4 --- /dev/null +++ b/backend/src/main/java/com/example/backend/domain/orders/dto/ProductInfoDto.java @@ -0,0 +1,16 @@ +package com.example.backend.domain.orders.dto; + +import lombok.*; + +@Builder + +public record ProductInfoDto ( + Long id, + String name, + int price, + String imgUrl, + int quantity +){ + + +} diff --git a/backend/src/main/java/com/example/backend/domain/orders/entity/Orders.java b/backend/src/main/java/com/example/backend/domain/orders/entity/Orders.java new file mode 100644 index 00000000..c3e91ff7 --- /dev/null +++ b/backend/src/main/java/com/example/backend/domain/orders/entity/Orders.java @@ -0,0 +1,75 @@ +package com.example.backend.domain.orders.entity; + + +import com.example.backend.domain.common.Address; +import com.example.backend.domain.member.entity.Member; +import com.example.backend.domain.orders.status.DeliveryStatus; +import com.example.backend.domain.productOrders.entity.ProductOrders; +import com.example.backend.global.baseEntity.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "orders") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Orders extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") // 외래 키 매핑 + private Member member; + + @OneToMany(mappedBy = "orders", cascade = CascadeType.ALL) + private List productOrdersList = new ArrayList<>(); + + @Column(name = "total_price", nullable = false) + private int totalPrice; + + @Enumerated(EnumType.STRING) + @Column(length = 50, nullable = false) + private DeliveryStatus deliveryStatus; + + @Embedded + private Address address; + + + @Builder(builderMethodName = "create") + public Orders(Member member, List productOrdersList, Address address) { + this.member = member; + calculateTotalPrice(productOrdersList); + addProductOrder(productOrdersList); + this.deliveryStatus = DeliveryStatus.READY; + this.address = address; + } + + /** + * 연관관계 편의 메서드 + */ + public void addProductOrder(List productOrdersList){ + for (ProductOrders productOrders : productOrdersList) { + this.productOrdersList.add(productOrders); + productOrders.addOrders(this); + } + } + + /** + * 주문가격 총합 조회 + */ + + private void calculateTotalPrice(List productOrdersList){ + totalPrice = productOrdersList.stream() + .mapToInt(ProductOrders::getTotalPrice) + .sum(); + } + + public void changeStatus(DeliveryStatus status){ + this.deliveryStatus = status; + } +} diff --git a/backend/src/main/java/com/example/backend/domain/orders/exception/OrdersErrorCode.java b/backend/src/main/java/com/example/backend/domain/orders/exception/OrdersErrorCode.java new file mode 100644 index 00000000..9743586d --- /dev/null +++ b/backend/src/main/java/com/example/backend/domain/orders/exception/OrdersErrorCode.java @@ -0,0 +1,26 @@ +package com.example.backend.domain.orders.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public enum OrdersErrorCode { + + BAD_REQUEST(HttpStatus.BAD_REQUEST, "400-1", "잘못된 요청입니다."), + REFERENCE_INTEGRITY_ERROR(HttpStatus.BAD_REQUEST, "400-2", "참조 무결성 에러 유효하지 않은 데이터"), + FORBIDDEN(HttpStatus.FORBIDDEN, "403-1", "접근 권한이 없습니다."), + NOT_FOUND(HttpStatus.NOT_FOUND,"404-1", "해당 리소스를 찾을 수 없습니다"), + UNABLE_ORDER_CANCEL_ALREADY_CANCEL(HttpStatus.CONFLICT, "409-1", "이미 취소된 상품입니다."), + UNABLE_ORDER_CANCEL_ALREADY_SHIPPED(HttpStatus.CONFLICT, "409-2", "이미 배송중입니다."), + SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "500-1", "서버 에러"); + + final HttpStatus httpStatus; + final String code; + final String message; + + OrdersErrorCode(HttpStatus httpStatus, String code, String message) { + this.httpStatus = httpStatus; + this.code = code; + this.message = message; + } +} diff --git a/backend/src/main/java/com/example/backend/domain/orders/exception/OrdersException.java b/backend/src/main/java/com/example/backend/domain/orders/exception/OrdersException.java new file mode 100644 index 00000000..29a6a250 --- /dev/null +++ b/backend/src/main/java/com/example/backend/domain/orders/exception/OrdersException.java @@ -0,0 +1,21 @@ +package com.example.backend.domain.orders.exception; + +import org.springframework.http.HttpStatus; + +public class OrdersException extends RuntimeException { + + private final OrdersErrorCode ordersErrorCode; + + public OrdersException(OrdersErrorCode ordersErrorCode) { + super(ordersErrorCode.message); + this.ordersErrorCode = ordersErrorCode; + } + + public HttpStatus getStatus() { + return ordersErrorCode.httpStatus; + } + + public String getCode() { + return ordersErrorCode.code; + } +} diff --git a/backend/src/main/java/com/example/backend/domain/orders/repository/OrdersRepository.java b/backend/src/main/java/com/example/backend/domain/orders/repository/OrdersRepository.java new file mode 100644 index 00000000..ddb0d656 --- /dev/null +++ b/backend/src/main/java/com/example/backend/domain/orders/repository/OrdersRepository.java @@ -0,0 +1,90 @@ +package com.example.backend.domain.orders.repository; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.example.backend.domain.orders.entity.Orders; +import com.example.backend.domain.orders.status.DeliveryStatus; + +public interface OrdersRepository extends JpaRepository { + + @Query("select distinct o from Orders o " + + "join fetch o.member m " + + "join fetch o.productOrdersList po " + + "join fetch po.product p " + + "where o.id = :id") + Optional findOrderById(@Param("id") Long id); + + @Query("select distinct o from Orders o " + + "join fetch o.member m " + + "join fetch o.productOrdersList po " + + "join fetch po.product p " + + "where m.id = :id and o.deliveryStatus = :status") + List findByMemberIdAndDeliveryStatus( + @Param("id") Long id, + @Param("status") DeliveryStatus status); + + @Query("select distinct o from Orders o " + + "join fetch o.member m " + + "join fetch o.productOrdersList po " + + "join fetch po.product p " + + "where m.id = :id and o.deliveryStatus in :status " + + "order by o.modifiedAt desc") + List findAllByMemberIdAndDeliveryStatusOrderByModifiedAt( + @Param("id") Long id, + @Param("status") List status); + + void deleteByMemberId(Long memberId); + + /** + * 배송 상태가 READY이며 modifiedAt가 startTime, endTime 사이인 주문을 조회합니다. + * @param startTime + * @param endTime + * @return {@link List} + */ + @Query(""" + SELECT o FROM Orders o + WHERE o.modifiedAt >= :startTime + AND o.modifiedAt < :endTime + AND o.deliveryStatus = 'READY' + """) + List findReadyOrders(@Param("startTime") ZonedDateTime startTime, @Param("endTime") ZonedDateTime endTime); + + /** + * 배송 상태가 READY이며 modifiedAt가 startTime, endTime 사이인 주문한 username을 조회합니다. + * @param startTime + * @param endTime + * @return {@link List} + */ + @Query(""" + SELECT o.member.username FROM Orders o + join o.member + WHERE o.modifiedAt >= :startTime + AND o.modifiedAt < :endTime + AND o.deliveryStatus = 'READY' + """) + List findUsernameByReady(@Param("startTime") ZonedDateTime startTime, @Param("endTime") ZonedDateTime endTime); + + /** + * 배송 상태가 READY이며 modifiedAt가 startTime, endTime 사이인
+ * 주문의 배송 상태를 SHIPPED로 변경합니다. + * @param startTime + * @param endTime + */ + @Modifying + @Query(""" + UPDATE Orders o + SET o.deliveryStatus = 'SHIPPED' + WHERE CAST(o.modifiedAt AS timestamp) >= CAST(:startTime AS timestamp) + AND CAST(o.modifiedAt AS timestamp) < CAST(:endTime AS timestamp) + AND o.deliveryStatus = 'READY' + """) + void bulkUpdateDeliveryStatus(@Param("startTime") ZonedDateTime startTime, + @Param("endTime") ZonedDateTime endTime); +} diff --git a/backend/src/main/java/com/example/backend/domain/orders/service/OrdersService.java b/backend/src/main/java/com/example/backend/domain/orders/service/OrdersService.java new file mode 100644 index 00000000..23addeba --- /dev/null +++ b/backend/src/main/java/com/example/backend/domain/orders/service/OrdersService.java @@ -0,0 +1,113 @@ +package com.example.backend.domain.orders.service; + +import java.util.List; +import java.util.Optional; + +import com.example.backend.domain.orders.converter.OrdersConverter; +import com.example.backend.domain.orders.dto.OrdersForm; +import com.example.backend.domain.product.entity.Product; +import com.example.backend.domain.product.exception.ProductErrorCode; +import com.example.backend.domain.product.exception.ProductException; +import com.example.backend.domain.product.repository.ProductRepository; +import com.example.backend.domain.productOrders.entity.ProductOrders; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.example.backend.domain.member.entity.Member; +import com.example.backend.domain.orders.dto.OrdersResponse; +import com.example.backend.domain.orders.entity.Orders; +import com.example.backend.domain.orders.exception.OrdersErrorCode; +import com.example.backend.domain.orders.exception.OrdersException; +import com.example.backend.domain.orders.repository.OrdersRepository; +import com.example.backend.domain.orders.status.DeliveryStatus; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Service +public class OrdersService { + + private final OrdersRepository ordersRepository; + private final ProductRepository productRepository; + + @Transactional(readOnly = true) + public OrdersResponse findOne(Long id) { + Orders orders = ordersRepository.findOrderById(id) + .orElseThrow(() -> new OrdersException(OrdersErrorCode.NOT_FOUND)); + + return OrdersConverter.toResponse(orders); + } + + @Transactional(readOnly = true) + public List current(Long id) { + List ordersList = Optional.ofNullable( + ordersRepository.findByMemberIdAndDeliveryStatus(id, DeliveryStatus.READY) + ).orElseThrow(() -> new OrdersException(OrdersErrorCode.NOT_FOUND)); + + return OrdersConverter.from(ordersList); + } + + @Transactional + public Long create(OrdersForm ordersForm, Member member) { + List productOrdersList = createProductOrdersList(ordersForm); + Orders orders = OrdersConverter.of(ordersForm, member, productOrdersList); + + return ordersRepository.save(orders).getId(); + } + + @Transactional + public List createProductOrdersList(OrdersForm ordersForm) { + return ordersForm.productOrdersRequestList().stream().map( + po -> { + try { + Product product = productRepository.findById(po.productId()) + .orElseThrow(() -> new ProductException(ProductErrorCode.NOT_FOUND)); + + // 주문 상품 생성 + return ProductOrders.create() + .product(product) + .price(product.getPrice()) + .quantity(po.quantity()) + .build(); + } catch (ObjectOptimisticLockingFailureException e) { + throw new ProductException(ProductErrorCode.CONFLICT); + } + } + ).toList(); + } + + public List history(Long id) { + List ordersList = Optional.ofNullable( + ordersRepository.findAllByMemberIdAndDeliveryStatusOrderByModifiedAt( + id, + List.of(DeliveryStatus.READY, DeliveryStatus.SHIPPED)) + ).orElseThrow(() -> new OrdersException(OrdersErrorCode.NOT_FOUND)); + + return OrdersConverter.from(ordersList); + } + + @Transactional + public void deleteByMemberId(Long id) { + ordersRepository.deleteByMemberId(id); + } + + @Transactional + public void cancelById(Long id) { + Orders orders = ordersRepository.findOrderById(id) + .orElseThrow(() -> new OrdersException(OrdersErrorCode.NOT_FOUND)); + + if (orders.getDeliveryStatus() == DeliveryStatus.CANCEL) { + throw new OrdersException(OrdersErrorCode.UNABLE_ORDER_CANCEL_ALREADY_CANCEL); + } + if (orders.getDeliveryStatus() == DeliveryStatus.SHIPPED) { + throw new OrdersException(OrdersErrorCode.UNABLE_ORDER_CANCEL_ALREADY_SHIPPED); + } + + orders.changeStatus(DeliveryStatus.CANCEL); + orders.getProductOrdersList() + .forEach(po -> po.restore(po.getQuantity())); + + ordersRepository.save(orders); + } +} diff --git a/backend/src/main/java/com/example/backend/domain/orders/status/DeliveryStatus.java b/backend/src/main/java/com/example/backend/domain/orders/status/DeliveryStatus.java new file mode 100644 index 00000000..60a99fe6 --- /dev/null +++ b/backend/src/main/java/com/example/backend/domain/orders/status/DeliveryStatus.java @@ -0,0 +1,16 @@ +package com.example.backend.domain.orders.status; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public enum DeliveryStatus { + + READY("배송 준비중"), + SHIPPED("배송 시작"), + CANCEL("주문 취소"); + + private String description; + +} diff --git a/backend/src/main/java/com/example/backend/domain/product/controller/ProductController.java b/backend/src/main/java/com/example/backend/domain/product/controller/ProductController.java new file mode 100644 index 00000000..0452e6bc --- /dev/null +++ b/backend/src/main/java/com/example/backend/domain/product/controller/ProductController.java @@ -0,0 +1,69 @@ +package com.example.backend.domain.product.controller; + +import com.example.backend.domain.product.dto.ProductForm; +import com.example.backend.domain.product.dto.ProductResponse; +import com.example.backend.domain.product.service.ProductService; +import com.example.backend.global.response.GenericResponse; +import com.example.backend.global.validation.ValidationSequence; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +/** + * ProductController + * "/products"로 들어오는 요청을 처리하는 컨트롤러 + * @author 100minha + */ +@RestController +@RequestMapping("/api/v1/products") +@RequiredArgsConstructor +public class ProductController { + private final ProductService productService; + + @GetMapping("/{id}") + public ResponseEntity> findById(@PathVariable("id") Long id) { + + ProductResponse productResponse = productService.findProductResponseById(id); + + return ResponseEntity.ok().body(GenericResponse.of(productResponse)); + } + + @GetMapping + public ResponseEntity>> findAllPaged( + @RequestParam(value = "page", defaultValue = "0") int page) { + + Page productResponsePage = productService.findAllPaged(page); + + return ResponseEntity.ok().body(GenericResponse.of(productResponsePage)); + } + + @PostMapping + public ResponseEntity> create(@RequestBody @Validated(ValidationSequence.class) ProductForm productForm) { + + productService.create(productForm); + + return ResponseEntity.status(HttpStatus.CREATED) + .body(GenericResponse.of("상품이 정상적으로 등록되었습니다.")); + } + + @PatchMapping("/{id}") + public ResponseEntity> modify(@PathVariable("id") Long id, + @RequestBody @Validated(ValidationSequence.class) ProductForm productForm) { + + productService.modify(id, productForm); + + return ResponseEntity.ok().body(GenericResponse.of("상품이 정상적으로 수정되었습니다.")); + } + + @DeleteMapping("/{id}") + public ResponseEntity> delete(@PathVariable("id") Long id) { + + productService.delete(id); + + return ResponseEntity.ok().body(GenericResponse.of("상품이 정상적으로 삭제되었습니다.")); + } + +} diff --git a/backend/src/main/java/com/example/backend/domain/product/converter/ProductConverter.java b/backend/src/main/java/com/example/backend/domain/product/converter/ProductConverter.java new file mode 100644 index 00000000..6f42ea2a --- /dev/null +++ b/backend/src/main/java/com/example/backend/domain/product/converter/ProductConverter.java @@ -0,0 +1,39 @@ +package com.example.backend.domain.product.converter; + +import com.example.backend.domain.product.dto.ProductForm; +import com.example.backend.domain.product.dto.ProductResponse; +import com.example.backend.domain.product.entity.Product; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * ProductConverter + * 엔티티, DTO간의 변환 메서드를 관리하는 클래스 + * + * @author 100minha + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class ProductConverter { + + public static Product from(ProductForm productForm) { + + return Product.builder() + .name(productForm.name()) + .content(productForm.content()) + .price(productForm.price()) + .imgUrl(productForm.imgUrl()) + .quantity(productForm.quantity()) + .build(); + } + + public static ProductResponse from(Product product) { + + return new ProductResponse( + product.getId(), + product.getName(), + product.getContent(), + product.getPrice(), + product.getImgUrl() + ); + } +} diff --git a/backend/src/main/java/com/example/backend/domain/product/dto/ProductForm.java b/backend/src/main/java/com/example/backend/domain/product/dto/ProductForm.java new file mode 100644 index 00000000..133f8f90 --- /dev/null +++ b/backend/src/main/java/com/example/backend/domain/product/dto/ProductForm.java @@ -0,0 +1,35 @@ +package com.example.backend.domain.product.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import lombok.Builder; +import org.hibernate.validator.constraints.Length; + +import static com.example.backend.global.validation.ValidationGroups.*; + +/** + * ProductForm + * 상품 등록 및 수정 시 사용하는 DTO + * @author 100 + */ +@Builder +public record ProductForm( + @NotBlank(message = "상품 이름은 공백일 수 없습니다.", groups = NotBlankGroup.class) + @Length(min = 2, max = 50, message = "상품 이름은 2자 이상 50자 이하여야 합니다.", groups = SizeGroup.class) + String name, + + @NotBlank(message = "상품 설명은 공백일 수 없습니다.", groups = NotBlankGroup.class) + String content, + + @Min(value = 100, message = "상품 가격은 100원 이상이어야 합니다.", groups = MinGroup.class) + @Max(value = 9999999, message = "상품 가격은 9,999,999원 이하여야 합니다.", groups = MaxGroup.class) + int price, + + String imgUrl, + + @Min(value = 0, message = "상품 수량은 0 이상이어야 합니다.", groups = MaxGroup.class) + int quantity +) { + +} diff --git a/backend/src/main/java/com/example/backend/domain/product/dto/ProductResponse.java b/backend/src/main/java/com/example/backend/domain/product/dto/ProductResponse.java new file mode 100644 index 00000000..4393fd3e --- /dev/null +++ b/backend/src/main/java/com/example/backend/domain/product/dto/ProductResponse.java @@ -0,0 +1,19 @@ +package com.example.backend.domain.product.dto; + +import lombok.Builder; + +/** + * ProductResponse + * 상품 조회 시 사용하는 DTO + * @author 100minha + */ +@Builder +public record ProductResponse( + Long id, + String name, + String content, + int price, + String imgUrl +) { + +} diff --git a/backend/src/main/java/com/example/backend/domain/product/entity/Product.java b/backend/src/main/java/com/example/backend/domain/product/entity/Product.java new file mode 100644 index 00000000..048a40e1 --- /dev/null +++ b/backend/src/main/java/com/example/backend/domain/product/entity/Product.java @@ -0,0 +1,79 @@ +package com.example.backend.domain.product.entity; + +import com.example.backend.domain.product.exception.ProductErrorCode; +import com.example.backend.domain.product.exception.ProductException; +import com.example.backend.domain.product.dto.ProductForm; +import com.example.backend.domain.product.exception.ProductErrorCode; +import com.example.backend.domain.product.exception.ProductException; + +import com.example.backend.domain.product.dto.ProductForm; +import com.example.backend.domain.product.exception.ProductErrorCode; +import com.example.backend.domain.product.exception.ProductException; +import com.example.backend.global.baseEntity.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * Product + * 상품 Entity + * @author 100minha + */ +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Product extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(length = 50, unique = true) + private String name; + + private String content; + + private int price; + + private String imgUrl; + + private int quantity; + + @Version + private Long version; + + @Builder + public Product(String name, String content, int price, String imgUrl, int quantity) { + this.name = name; + this.content = content; + this.price = price; + this.imgUrl = imgUrl; + this.quantity = quantity; + } + + /** + * 상품 재고 감소 로직 + */ + public void removeQuantity(int quantity) { + + int restQuantity = this.quantity - quantity; + if(restQuantity < 0) { + throw new ProductException(ProductErrorCode.INSUFFICIENT_QUANTITY); + } + this.quantity = restQuantity; + } + public void modify(ProductForm productForm) { + + this.name = productForm.name(); + this.content = productForm.content(); + this.price = productForm.price(); + this.imgUrl = productForm.imgUrl(); + this.quantity = productForm.quantity(); + } + + public void restore(int quantity) { + this.quantity += quantity; + } + +} diff --git a/backend/src/main/java/com/example/backend/domain/product/exception/ProductErrorCode.java b/backend/src/main/java/com/example/backend/domain/product/exception/ProductErrorCode.java new file mode 100644 index 00000000..df1c8316 --- /dev/null +++ b/backend/src/main/java/com/example/backend/domain/product/exception/ProductErrorCode.java @@ -0,0 +1,25 @@ +package com.example.backend.domain.product.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +/** + * ProductErrorCode + * 상품 비지니스 로직에서 발생하는 예외 코드를 정의하는 Enum 클래스입니다. + * + * @author 100minha + */ +@AllArgsConstructor +@Getter +public enum ProductErrorCode { + INSUFFICIENT_QUANTITY(HttpStatus.BAD_REQUEST, "상품 재고가 부족합니다.", "400-1"), + EXISTS_NAME(HttpStatus.BAD_REQUEST, "중복된 상품 이름입니다.", "400-2"), + EXISTS_ORDER_HISTORY(HttpStatus.BAD_REQUEST, "주문 내역이 존재하는 상품입니다.", "400-3"), + NOT_FOUND(HttpStatus.NOT_FOUND, "상품을 찾을 수 없습니다.", "404"), + CONFLICT(HttpStatus.CONFLICT, "현재 다른 사용자가 해당 상품을 처리 중입니다. 잠시 후 다시 시도해 주세요.", "409-1"); + + final HttpStatus httpStatus; + final String message; + final String code; +} diff --git a/backend/src/main/java/com/example/backend/domain/product/exception/ProductException.java b/backend/src/main/java/com/example/backend/domain/product/exception/ProductException.java new file mode 100644 index 00000000..6c83ff2c --- /dev/null +++ b/backend/src/main/java/com/example/backend/domain/product/exception/ProductException.java @@ -0,0 +1,26 @@ +package com.example.backend.domain.product.exception; + +import org.springframework.http.HttpStatus; + +/** + * ProductException + * 상품 관련 예외 처리 클래스 + * + * @author 100minha + */ +public class ProductException extends RuntimeException { + private final ProductErrorCode productErrorCode; + + public ProductException(ProductErrorCode productErrorCode) { + super(productErrorCode.getMessage()); + this.productErrorCode = productErrorCode; + } + + public HttpStatus getStatus() { + return productErrorCode.httpStatus; + } + + public String getCode() { + return productErrorCode.code; + } +} diff --git a/backend/src/main/java/com/example/backend/domain/product/repository/ProductRepository.java b/backend/src/main/java/com/example/backend/domain/product/repository/ProductRepository.java new file mode 100644 index 00000000..831a894d --- /dev/null +++ b/backend/src/main/java/com/example/backend/domain/product/repository/ProductRepository.java @@ -0,0 +1,36 @@ +package com.example.backend.domain.product.repository; + + +import com.example.backend.domain.product.dto.ProductResponse; +import com.example.backend.domain.product.entity.Product; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * ProductRepository + * 상품 관련 Repository + * @author 100minha + */ +@Repository +public interface ProductRepository extends JpaRepository { + @Query("SELECT new com.example.backend.domain.product.dto.ProductResponse(p.id, p.name, p.content, " + + "p.price, p.imgUrl) " + + "FROM Product p " + + "WHERE p.id = :id") + Optional findProductResponseById(@Param("id") Long id); + + @Query("SELECT new com.example.backend.domain.product.dto.ProductResponse(p.id, p.name, p.content, p.price, p.imgUrl) " + + "FROM Product p") + Page findAllPaged(Pageable pageable); + + boolean existsByName(String name); + + boolean existsByNameAndIdNot(String name, Long id); + +} \ No newline at end of file diff --git a/backend/src/main/java/com/example/backend/domain/product/service/ProductService.java b/backend/src/main/java/com/example/backend/domain/product/service/ProductService.java new file mode 100644 index 00000000..17c08494 --- /dev/null +++ b/backend/src/main/java/com/example/backend/domain/product/service/ProductService.java @@ -0,0 +1,107 @@ +package com.example.backend.domain.product.service; + +import com.example.backend.domain.product.converter.ProductConverter; +import com.example.backend.domain.product.dto.ProductForm; +import com.example.backend.domain.product.dto.ProductResponse; +import com.example.backend.domain.product.entity.Product; +import com.example.backend.domain.product.exception.ProductErrorCode; +import com.example.backend.domain.product.exception.ProductException; +import com.example.backend.domain.product.repository.ProductRepository; +import com.example.backend.domain.productOrders.repository.ProductOrdersRepository; +import lombok.RequiredArgsConstructor; +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.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * ProductServiceImpl + * 상품 관련 서비스 로직 구현 + * @author 100minha + */ +@Service +@RequiredArgsConstructor +public class ProductService { + + private final ProductRepository productRepository; + private final ProductOrdersRepository productOrdersRepository; + + @Transactional(readOnly = true) + public Product findById(Long id) { + + return productRepository.findById(id).orElseThrow(() + -> new ProductException(ProductErrorCode.NOT_FOUND)); + } + + @Transactional(readOnly = true) + public ProductResponse findProductResponseById(Long id) { + + return productRepository.findProductResponseById(id).orElseThrow(() + -> new ProductException(ProductErrorCode.NOT_FOUND)); + } + + @Transactional(readOnly = true) + public Page findAllPaged(int page) { + + Sort sortByNameAsc = Sort.by(Sort.Order.asc("name")); + Pageable pageable = PageRequest.of(page, 10, sortByNameAsc); + + Page productResponsePage = productRepository.findAllPaged(pageable); + + if(productResponsePage.isEmpty()) { + throw new ProductException(ProductErrorCode.NOT_FOUND); + } + + return productResponsePage; + } + + /** + * 상품 등록 시 이름 중복 검증 메서드 + * @param name + */ + private void existsProduct(String name) { + + if(productRepository.existsByName(name)) { + throw new ProductException(ProductErrorCode.EXISTS_NAME); + } + } + + /** + * 상품 수정 시 이름 중복 검증 메서드 + * 수정시엔 해당 상품의 기존 이름은 중복 검증에서 제외 + * @param id + * @param name + */ + private void existsProduct(Long id, String name) { + + if(productRepository.existsByNameAndIdNot(name, id)) { + throw new ProductException(ProductErrorCode.EXISTS_NAME); + } + } + + @Transactional + public void create(ProductForm productForm) { + + existsProduct(productForm.name()); + productRepository.save(ProductConverter.from(productForm)); + } + + @Transactional + public void modify(Long id, ProductForm productForm) { + + existsProduct(id, productForm.name()); + findById(id).modify(productForm); + } + + @Transactional + public void delete(Long id) { + + if(productOrdersRepository.existsByProductId(id)) { + throw new ProductException(ProductErrorCode.EXISTS_ORDER_HISTORY); + } + + productRepository.delete(findById(id)); + } +} diff --git a/backend/src/main/java/com/example/backend/domain/productOrders/entity/ProductOrders.java b/backend/src/main/java/com/example/backend/domain/productOrders/entity/ProductOrders.java new file mode 100644 index 00000000..d71eef8d --- /dev/null +++ b/backend/src/main/java/com/example/backend/domain/productOrders/entity/ProductOrders.java @@ -0,0 +1,66 @@ +package com.example.backend.domain.productOrders.entity; + + +import com.example.backend.domain.orders.entity.Orders; +import com.example.backend.domain.product.entity.Product; +import com.example.backend.global.baseEntity.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "product_orders") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProductOrders extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "orders_id") + private Orders orders; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "product_id") + private Product product; + @Column(name = "quantity", nullable = false) + private int quantity; + @Column(name = "price", nullable = false) + private int price; + + /** + * 생성 메서드 + */ + @Builder(builderMethodName = "create") + public ProductOrders(Product product, int quantity, int price) { + this.product = product; + this.quantity = quantity; + this.price = price; + + product.removeQuantity(quantity); // 주문 수량 만큼 감소 + } + + /** + * 연관관계 편의 메서드 + */ + + public void addOrders(Orders orders){ + this.orders = orders; + } + /** + * 주문상품 가격 총합 조회 + */ + public int getTotalPrice() { + return getPrice() * getQuantity(); + } + + /** + * 상품 수량 복구 + */ + public void restore(int quantity) { + product.restore(quantity); + } + +} diff --git a/backend/src/main/java/com/example/backend/domain/productOrders/repository/ProductOrdersRepository.java b/backend/src/main/java/com/example/backend/domain/productOrders/repository/ProductOrdersRepository.java new file mode 100644 index 00000000..8b671dbd --- /dev/null +++ b/backend/src/main/java/com/example/backend/domain/productOrders/repository/ProductOrdersRepository.java @@ -0,0 +1,14 @@ +package com.example.backend.domain.productOrders.repository; + +import com.example.backend.domain.productOrders.entity.ProductOrders; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductOrdersRepository extends JpaRepository { + + /** + * 상품 id로 해당 상품 주문 내역 존재 여부 검증 메서드 + * @param id + * @return boolean + */ + boolean existsByProductId(Long id); +} diff --git a/backend/src/main/java/com/example/backend/global/advice/GlobalControllerAdvice.java b/backend/src/main/java/com/example/backend/global/advice/GlobalControllerAdvice.java new file mode 100644 index 00000000..33171212 --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/advice/GlobalControllerAdvice.java @@ -0,0 +1,196 @@ +package com.example.backend.global.advice; + +import com.example.backend.domain.cart.exception.CartException; +import com.example.backend.domain.member.exception.MemberException; +import com.example.backend.domain.orders.exception.OrdersException; +import com.example.backend.domain.product.exception.ProductException; +import com.example.backend.global.auth.exception.AuthException; +import com.example.backend.global.exception.GlobalErrorCode; +import com.example.backend.global.exception.GlobalException; +import com.example.backend.global.response.ErrorDetail; +import com.example.backend.global.response.HttpErrorInfo; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.BindingResult; +import org.springframework.validation.FieldError; +import org.springframework.validation.ObjectError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +/** + * GlobalControllerAdvice + *

애플리케이션 전역에서 발생하는 예외를 처리하는 클래스 입니다.

+ * + * @author Kim Dong O + */ +@ControllerAdvice +@Slf4j +public class GlobalControllerAdvice { + + /** + * Validation 예외 발생시 처리하는 핸들러 + * + * @param ex Exception + * @param request HttpServletRequest + * @return {@link ResponseEntity} + */ + @ExceptionHandler({MethodArgumentNotValidException.class}) + public ResponseEntity handlerMethodArgumentNotValidException(MethodArgumentNotValidException ex, + HttpServletRequest request) { + log.error("handlerMethodArgumentNotValidException", ex); + BindingResult bindingResult = ex.getBindingResult(); + List errors = new ArrayList<>(); + GlobalErrorCode globalErrorCode = GlobalErrorCode.NOT_VALID; + + //Field 에러 처리 + for (FieldError error : bindingResult.getFieldErrors()) { + ErrorDetail customError = ErrorDetail.of(error.getField(), error.getDefaultMessage()); + + errors.add(customError); + } + + //Object 에러 처리 + for (ObjectError globalError : bindingResult.getGlobalErrors()) { + ErrorDetail customError = ErrorDetail.of( + globalError.getObjectName(), + globalError.getDefaultMessage() + ); + + errors.add(customError); + } + + return ResponseEntity.status(ex.getStatusCode().value()) + .body(HttpErrorInfo.of( + GlobalErrorCode.NOT_VALID.getCode(), + request.getRequestURI(), + GlobalErrorCode.NOT_VALID.getMessage(), + errors) + ); + } + + /** + * Validation 예외 발생시 처리하는 핸들러 + * + * @param ex Exception + * @param request HttpServletRequest + * @return {@link ResponseEntity} + */ + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity handleConstraintViolationException(ConstraintViolationException ex, + HttpServletRequest request) { + log.error("handleConstraintViolationException", ex); + + Set> constraintViolations = ex.getConstraintViolations(); + List errorDetails = new ArrayList<>(); + + for (ConstraintViolation constraintViolation : constraintViolations) { + errorDetails.add(ErrorDetail.of( + constraintViolation.getPropertyPath().toString(), + constraintViolation.getMessage() + )); + } + + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(HttpErrorInfo.of(GlobalErrorCode.NOT_VALID.getCode(), request.getRequestURI(), + GlobalErrorCode.NOT_VALID.getMessage(), + errorDetails) + ); + } + + /** + * Member 예외 발생시 처리하는 핸들러 + * + * @param ex Exception + * @param request HttpServletRequest + * @return {@link ResponseEntity} + */ + @ExceptionHandler(MemberException.class) + public ResponseEntity handlerMemberException(MemberException ex, HttpServletRequest request) { + log.error("handlerMemberException", ex); + return ResponseEntity.status(ex.getStatus()) + .body(HttpErrorInfo.of(ex.getCode(), request.getRequestURI(), ex.getMessage())); + } + + @ExceptionHandler(GlobalException.class) + public ResponseEntity handlerGlobalException(GlobalException ex, HttpServletRequest request) { + log.error("handlerGlobalException", ex); + return ResponseEntity.status(ex.getStatus()) + .body(HttpErrorInfo.of(ex.getCode(), request.getRequestURI(), ex.getMessage())); + } + + @ExceptionHandler(AuthException.class) + public ResponseEntity handlerAuthException(AuthException ex, HttpServletRequest request) { + log.error("handlerAuthException", ex); + return ResponseEntity.status(ex.getStatus()) + .body(HttpErrorInfo.of(ex.getCode(), request.getRequestURI(), ex.getMessage())); + } + + /** + * Product 예외 발생시 처리하는 핸들러 + * + * @param ex ProductException + * @param request HttpServletRequest + * @return {@link ResponseEntity} + */ + @ExceptionHandler(ProductException.class) + public ResponseEntity handlerProductException(ProductException ex, HttpServletRequest request) { + log.error("handlerProductException", ex); + return ResponseEntity.status(ex.getStatus()) + .body(HttpErrorInfo.of(ex.getCode(), request.getRequestURI(), ex.getMessage())); + } + + /** + * @param ex HttpMessageNotReadableException + * @param request HttpServletRequest + * @return {@link ResponseEntity} + * @RequestBody에서 파싱할 수 없는 값 들어올 시 발생하는 예외 처리 핸들러 + */ + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handlerHttpMessageNotReadableException(HttpMessageNotReadableException ex, + HttpServletRequest request) { + log.error("handlerHttpMessageNotReadableException", ex); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(HttpErrorInfo.of("400", request.getRequestURI(), ex.getMessage())); + } + + + /** + * Cart 예외 발생시 처리하는 핸들러 + * + * @param ex CartException + * @param request HttpServletRequest + * @return {@link ResponseEntity} + */ + + @ExceptionHandler(CartException.class) + public ResponseEntity handlerCartException(CartException ex, HttpServletRequest request) { + log.info("GlobalControllerAdvice={}", ex); + return ResponseEntity.status(ex.getStatus()) + .body(HttpErrorInfo.of(ex.getCode(), request.getRequestURI(), ex.getMessage())); + } + + /** + * Order 예외 발생시 처리하는 핸들러 + * + * @param ex OrderException + * @param request HttpServletRequest + * @return {@link ResponseEntity} + */ + @ExceptionHandler(OrdersException.class) + public ResponseEntity handlerOrderException(OrdersException ex, HttpServletRequest request) { + log.info("GlobalControllerAdvice={}", ex); + return ResponseEntity.status(ex.getStatus()) + .body(HttpErrorInfo.of(ex.getCode(), request.getRequestURI(), ex.getMessage())); + } +} diff --git a/backend/src/main/java/com/example/backend/global/auth/controller/AuthController.java b/backend/src/main/java/com/example/backend/global/auth/controller/AuthController.java new file mode 100644 index 00000000..e0801026 --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/auth/controller/AuthController.java @@ -0,0 +1,79 @@ +package com.example.backend.global.auth.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +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 com.example.backend.global.auth.dto.AuthForm; +import com.example.backend.global.auth.dto.AuthLoginResponse; +import com.example.backend.global.auth.dto.AuthResponse; +import com.example.backend.global.auth.dto.EmailCertificationForm; +import com.example.backend.global.auth.dto.SendEmailCertificationCodeForm; +import com.example.backend.global.auth.service.AuthService; +import com.example.backend.global.auth.service.CookieService; +import com.example.backend.global.response.GenericResponse; +import com.example.backend.global.validation.ValidationSequence; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/v1/auth") +@RequiredArgsConstructor +@Tag(name = "ApiV1AuthController", description = "API 인증 컨트롤러") +public class AuthController { + + private final AuthService authService; + private final CookieService cookieService; + + @Operation(summary = "로그인", description = "accessToken, refreshToken을 발급, 쿠키로 전달") + @PostMapping("/login") + public ResponseEntity> login( + @RequestBody @Validated(ValidationSequence.class) AuthForm authForm, HttpServletResponse response) { + AuthResponse authResponse = authService.login(authForm); + + cookieService.addAccessTokenToCookie(authResponse.accessToken(), response); + cookieService.addRefreshTokenToCookie(authResponse.refreshToken(), response); + + return ResponseEntity.status(HttpStatus.OK).body(GenericResponse.of( + AuthLoginResponse.of(authResponse.username(), authResponse.nickname()), "로그인 성공")); + } + + @Operation(summary = "로그아웃", description = "accessToken, refreshToken 을 제거") + @PostMapping("/logout") + public ResponseEntity> logout(HttpServletRequest request, HttpServletResponse response) { + String refreshToken = cookieService.getRefreshTokenFromRequest(request); + + authService.logout(refreshToken); + cookieService.deleteAccessTokenFromCookie(response); + cookieService.deleteRefreshTokenFromCookie(response); + + return ResponseEntity.status(HttpStatus.OK).body(GenericResponse.of("로그아웃 성공")); + } + + @Operation(summary = "이메일 인증") + @PostMapping("/verify") + public ResponseEntity> verify(@RequestBody @Validated(ValidationSequence.class) + EmailCertificationForm emailCertificationForm) { + authService.verify(emailCertificationForm.username(), emailCertificationForm.certificationCode(), + emailCertificationForm.verifyType()); + + return ResponseEntity.ok().body(GenericResponse.of()); + } + + @Operation(summary = "이메일 인증 코드 발송") + @PostMapping("/code") + public ResponseEntity> code(@RequestBody @Validated(ValidationSequence.class) + SendEmailCertificationCodeForm sendEmailCertificationCodeForm) { + authService.send(sendEmailCertificationCodeForm.username(), sendEmailCertificationCodeForm.verifyType()); + + return ResponseEntity.ok().body(GenericResponse.of()); + } +} diff --git a/backend/src/main/java/com/example/backend/global/auth/dto/AuthForm.java b/backend/src/main/java/com/example/backend/global/auth/dto/AuthForm.java new file mode 100644 index 00000000..46fb75ca --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/auth/dto/AuthForm.java @@ -0,0 +1,10 @@ +package com.example.backend.global.auth.dto; + +import static com.example.backend.global.validation.ValidationGroups.*; + +import com.example.backend.global.validation.annotation.ValidUsername; + +import lombok.Builder; + +@Builder +public record AuthForm(@ValidUsername(groups = PatternGroup.class) String username, String password) {} diff --git a/backend/src/main/java/com/example/backend/global/auth/dto/AuthLoginResponse.java b/backend/src/main/java/com/example/backend/global/auth/dto/AuthLoginResponse.java new file mode 100644 index 00000000..d9724325 --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/auth/dto/AuthLoginResponse.java @@ -0,0 +1,13 @@ +package com.example.backend.global.auth.dto; + +import com.example.backend.domain.member.entity.Role; + +import lombok.Builder; + +@Builder +public record AuthLoginResponse (String username, String nickname) { + + public static AuthLoginResponse of(String username, String nickname) { + return new AuthLoginResponse(username, nickname); + } +} diff --git a/backend/src/main/java/com/example/backend/global/auth/dto/AuthResponse.java b/backend/src/main/java/com/example/backend/global/auth/dto/AuthResponse.java new file mode 100644 index 00000000..d4d7928e --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/auth/dto/AuthResponse.java @@ -0,0 +1,11 @@ +package com.example.backend.global.auth.dto; + +import lombok.Builder; + +@Builder +public record AuthResponse (String username, String nickname, String accessToken, String refreshToken) { + + public static AuthResponse of(String username, String nickname, String accessToken, String refreshToken) { + return new AuthResponse(username, nickname, accessToken, refreshToken); + } +} diff --git a/backend/src/main/java/com/example/backend/global/auth/dto/EmailCertificationForm.java b/backend/src/main/java/com/example/backend/global/auth/dto/EmailCertificationForm.java new file mode 100644 index 00000000..415c7256 --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/auth/dto/EmailCertificationForm.java @@ -0,0 +1,20 @@ +package com.example.backend.global.auth.dto; + +import static com.example.backend.global.validation.ValidationGroups.*; + +import com.example.backend.domain.common.VerifyType; +import com.example.backend.global.validation.annotation.ValidEnum; +import com.example.backend.global.validation.annotation.ValidUsername; + +import jakarta.validation.constraints.NotBlank; +import lombok.Builder; + +@Builder +public record EmailCertificationForm( + @ValidUsername(groups = PatternGroup.class) + String username, + @NotBlank(message = "인증 코드는 필수 항목 입니다.", groups = NotBlankGroup.class) + String certificationCode, + @ValidEnum(enumClass = VerifyType.class, message = "인증 타입은 필수 항목 입니다.", groups = ValidEnumGroup.class) + VerifyType verifyType) { +} diff --git a/backend/src/main/java/com/example/backend/global/auth/dto/SendEmailCertificationCodeForm.java b/backend/src/main/java/com/example/backend/global/auth/dto/SendEmailCertificationCodeForm.java new file mode 100644 index 00000000..e27e2266 --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/auth/dto/SendEmailCertificationCodeForm.java @@ -0,0 +1,20 @@ +package com.example.backend.global.auth.dto; + +import static com.example.backend.global.validation.ValidationGroups.*; + +import com.example.backend.domain.common.VerifyType; +import com.example.backend.global.validation.annotation.ValidEnum; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.Builder; + +@Builder +public record SendEmailCertificationCodeForm( + @NotBlank(message = "이메일은 필수 항목입니다.", groups = NotBlankGroup.class) + @Pattern(regexp = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,6}$", + message = "유효하지 않은 이메일 입니다.", groups = PatternGroup.class) + String username, + @ValidEnum(message = "지원하지 않는 인증 유형입니다.", groups = ValidEnumGroup.class, enumClass = VerifyType.class) + VerifyType verifyType) { +} diff --git a/backend/src/main/java/com/example/backend/global/auth/exception/AuthErrorCode.java b/backend/src/main/java/com/example/backend/global/auth/exception/AuthErrorCode.java new file mode 100644 index 00000000..4d7aa0ac --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/auth/exception/AuthErrorCode.java @@ -0,0 +1,41 @@ +package com.example.backend.global.auth.exception; + +import org.springframework.http.HttpStatus; + +import lombok.Getter; + +/** + * AuthErrorCode + *

Auth 예외 발생시 예외 코드를 정의하는 Enum 클래스 입니다.

+ * + * @author vdvhk12 + */ +@Getter +public enum AuthErrorCode { + UNKNOWN_SERVER(HttpStatus.INTERNAL_SERVER_ERROR, "500-1", "요청 처리중 서버에서 예외가 발생했습니다."), + MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "404-1", "해당 유저가 존재하지 않습니다."), + CERTIFICATION_CODE_NOT_FOUND(HttpStatus.NOT_FOUND, "404-2", "해당 이메일의 인증 코드 정보가 존재하지 않습니다."), + + PASSWORD_NOT_MATCH(HttpStatus.UNAUTHORIZED, "401-1", "비밀번호가 일치하지 않습니다."), + TOKEN_NOT_VALID(HttpStatus.UNAUTHORIZED, "401-2", "유효하지 않은 토큰입니다."), + TOKEN_MISSING(HttpStatus.UNAUTHORIZED, "401-3", "토큰이 없습니다."), + VERIFY_TYPE_NOT_MATCH(HttpStatus.UNAUTHORIZED, "401-4", "인증 타입이 일치하지 않습니다."), + CERTIFICATION_CODE_NOT_MATCH(HttpStatus.UNAUTHORIZED, "401-5", "인증 코드가 일치하지 않습니다."), + NOT_CERTIFICATION(HttpStatus.UNAUTHORIZED, "401-6", "이메일 인증을 하지 않았습니다."), + REFRESH_TOKEN_NOT_VALID(HttpStatus.UNAUTHORIZED, "401-7", "유효하지 않은 리프레시 토큰입니다."), + REFRESH_TOKEN_NOT_MATCH(HttpStatus.UNAUTHORIZED, "401-8", "저장된 리프레시 토큰과 일치하지 않습니다."), + REFRESH_TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "401-9", "리프레시 토큰을 찾을 수 없습니다."), + + ALREADY_CERTIFIED(HttpStatus.BAD_REQUEST, "400-1", "이미 이메일 인증을 하셨습니다."), + TOO_MANY_RESEND_ATTEMPTS(HttpStatus.BAD_REQUEST, "400-2", "5회 이상 시도하셨습니다. 잠시후 다시 시도해주세요."); + + final HttpStatus httpStatus; + final String code; + final String message; + + AuthErrorCode(HttpStatus httpStatus, String code, String message) { + this.httpStatus = httpStatus; + this.code = code; + this.message = message; + } +} diff --git a/backend/src/main/java/com/example/backend/global/auth/exception/AuthException.java b/backend/src/main/java/com/example/backend/global/auth/exception/AuthException.java new file mode 100644 index 00000000..d3a9f91c --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/auth/exception/AuthException.java @@ -0,0 +1,20 @@ +package com.example.backend.global.auth.exception; + +import org.springframework.http.HttpStatus; + +public class AuthException extends RuntimeException { + private AuthErrorCode authErrorCode; + + public AuthException(AuthErrorCode authErrorCode) { + super(authErrorCode.message); + this.authErrorCode = authErrorCode; + } + + public HttpStatus getStatus() { + return authErrorCode.httpStatus; + } + + public String getCode() { + return authErrorCode.code; + } +} diff --git a/backend/src/main/java/com/example/backend/global/auth/filter/JwtAuthorizationFilter.java b/backend/src/main/java/com/example/backend/global/auth/filter/JwtAuthorizationFilter.java new file mode 100644 index 00000000..77fa405f --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/auth/filter/JwtAuthorizationFilter.java @@ -0,0 +1,73 @@ +package com.example.backend.global.auth.filter; + +import com.example.backend.global.auth.exception.AuthErrorCode; +import com.example.backend.global.auth.jwt.JwtUtils; +import com.example.backend.global.auth.model.CustomUserDetails; +import com.example.backend.global.auth.service.CookieService; +import com.example.backend.global.auth.service.CustomUserDetailsService; +import com.example.backend.global.auth.util.FilterUtils; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.web.filter.OncePerRequestFilter; + +@RequiredArgsConstructor +public class JwtAuthorizationFilter extends OncePerRequestFilter { + + private final JwtUtils jwtUtils; + private final FilterUtils filterUtils; + private final CookieService cookieService; + private final CustomUserDetailsService customUserDetailsService; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + if(filterUtils.isUnprotectedUrl(request)) { + filterChain.doFilter(request, response); + return; + } + + String accessToken = cookieService.getAccessTokenFromRequest(request); + if (accessToken == null) { + String refreshToken = cookieService.getRefreshTokenFromRequest(request); + if(refreshToken == null) { + filterUtils.createErrorInfo(AuthErrorCode.REFRESH_TOKEN_NOT_FOUND, request, response); + return; + } + // 액세스 토큰이 쿠키에 없을 때, 리프레시 토큰 필터 처리 + filterChain.doFilter(request, response); + return; + } + + String validationResult = jwtUtils.validateToken(accessToken); + if(validationResult.equals("invalid")) { + filterUtils.createErrorInfo(AuthErrorCode.TOKEN_NOT_VALID, request, response); + return; + } else if(validationResult.equals("expired")) { + // 쿠키에 값이 있는데, 액세스 토큰이 만료 되었을 때, 리프레시 토큰 필터 처리 + filterChain.doFilter(request, response); + return; + } + + try { + CustomUserDetails customUserDetails = (CustomUserDetails) customUserDetailsService.loadUserByUsername( + jwtUtils.getUsernameFromToken(accessToken)); + + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + customUserDetails, null, customUserDetails.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(authentication); + } catch (UsernameNotFoundException e) { + filterUtils.createErrorInfo(AuthErrorCode.MEMBER_NOT_FOUND, request, response); + } + + filterChain.doFilter(request, response); + } +} diff --git a/backend/src/main/java/com/example/backend/global/auth/filter/RefreshTokenFilter.java b/backend/src/main/java/com/example/backend/global/auth/filter/RefreshTokenFilter.java new file mode 100644 index 00000000..344c9cac --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/auth/filter/RefreshTokenFilter.java @@ -0,0 +1,90 @@ +package com.example.backend.global.auth.filter; + +import com.example.backend.domain.member.entity.Role; +import com.example.backend.global.auth.exception.AuthErrorCode; +import com.example.backend.global.auth.jwt.JwtProvider; +import com.example.backend.global.auth.jwt.JwtUtils; +import com.example.backend.global.auth.model.CustomUserDetails; +import com.example.backend.global.auth.service.CookieService; +import com.example.backend.global.auth.service.CustomUserDetailsService; +import com.example.backend.global.auth.service.RefreshTokenService; +import com.example.backend.global.auth.util.FilterUtils; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.web.filter.OncePerRequestFilter; + +@RequiredArgsConstructor +public class RefreshTokenFilter extends OncePerRequestFilter { + + private final JwtProvider jwtProvider; + private final JwtUtils jwtUtils; + private final FilterUtils filterUtils; + private final CookieService cookieService; + private final RefreshTokenService refreshTokenService; + private final CustomUserDetailsService customUserDetailsService; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + if(filterUtils.isUnprotectedUrl(request)) { + filterChain.doFilter(request, response); + return; + } + + // Step 1: SecurityContextHolder 확인 + if (SecurityContextHolder.getContext().getAuthentication() != null) { + // 이미 인증된 사용자인 경우 다음 필터로 넘김 + filterChain.doFilter(request, response); + return; + } + + // step2: 리프레시 토큰의 사용자 정보 추출 후, 저장된 리프레시 토큰과 일치하는지 검증하고 에러핸들링 + String refreshToken = cookieService.getRefreshTokenFromRequest(request); + String username = jwtUtils.getUsernameFromToken(refreshToken); + if(!refreshTokenService.isValidRefreshToken(username, refreshToken)) { + logout(response); + filterUtils.createErrorInfo(AuthErrorCode.REFRESH_TOKEN_NOT_MATCH, request, response); + return; + } + + // step3: 새로운 액세스 토큰, 리프레시 토큰 생성 + Long id = jwtUtils.getUserIdFromToken(refreshToken); + Role role = Role.valueOf(jwtUtils.getRoleFromToken(refreshToken)); + String newAccessToken = jwtProvider.generateAccessToken(id, username, role); + String newRefreshToken = jwtProvider.generateRefreshToken(id, username, role); + + // step4: SecurityContext 에 사용자 정보 추가 + try { + CustomUserDetails customUserDetails = (CustomUserDetails) customUserDetailsService.loadUserByUsername(username); + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + customUserDetails, null, customUserDetails.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(authentication); + } catch (UsernameNotFoundException e) { + filterUtils.createErrorInfo(AuthErrorCode.MEMBER_NOT_FOUND, request, response); + } + + // step5: 액세스 토큰을 쿠키에 추가 + cookieService.addAccessTokenToCookie(newAccessToken, response); + cookieService.addRefreshTokenToCookie(newRefreshToken, response); + + // step6: 레디스에 리프레시 토큰 저장 + refreshTokenService.saveRefreshToken(username, newRefreshToken); + + // step7: 다음 필터로 요청 전달 + filterChain.doFilter(request, response); + } + + private void logout(HttpServletResponse response) { + cookieService.deleteAccessTokenFromCookie(response); + cookieService.deleteRefreshTokenFromCookie(response); + SecurityContextHolder.clearContext(); + } +} diff --git a/backend/src/main/java/com/example/backend/global/auth/jwt/JwtProvider.java b/backend/src/main/java/com/example/backend/global/auth/jwt/JwtProvider.java new file mode 100644 index 00000000..d5dc73ca --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/auth/jwt/JwtProvider.java @@ -0,0 +1,35 @@ +package com.example.backend.global.auth.jwt; + +import com.example.backend.global.config.JwtConfig; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import java.util.Date; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import com.example.backend.domain.member.entity.Role; + +@Component +@RequiredArgsConstructor +public class JwtProvider { + + private final JwtConfig jwtConfig; + + public String generateAccessToken(Long id, String username, Role role) { + return generateToken(username, id, role, jwtConfig.getAccessTokenExpirationTime()); + } + + public String generateRefreshToken(Long id, String username, Role role) { + return generateToken(username, id, role, jwtConfig.getRefreshTokenExpirationTime()); + } + + private String generateToken(String username, Long id, Role role, Long expirationTime) { + return Jwts.builder() + .setSubject(username) + .claim("id", id) + .claim("role", role.name()) + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + expirationTime)) + .signWith(jwtConfig.getSecretKey(), SignatureAlgorithm.HS256) + .compact(); + } +} diff --git a/backend/src/main/java/com/example/backend/global/auth/jwt/JwtUtils.java b/backend/src/main/java/com/example/backend/global/auth/jwt/JwtUtils.java new file mode 100644 index 00000000..db9aa73a --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/auth/jwt/JwtUtils.java @@ -0,0 +1,52 @@ +package com.example.backend.global.auth.jwt; + +import com.example.backend.global.config.JwtConfig; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class JwtUtils { + + private final JwtConfig jwtConfig; + private final Logger logger = LoggerFactory.getLogger(JwtUtils.class); + + public Claims parseClaims(String token) { + return Jwts.parserBuilder() + .setSigningKey(jwtConfig.getSecretKey()) + .build() + .parseClaimsJws(token) + .getBody(); + } + + public String getUsernameFromToken(String token) { + return parseClaims(token).getSubject(); + } + + public Long getUserIdFromToken(String token) { + return parseClaims(token).get("id", Long.class); + } + + public String getRoleFromToken(String token) { + return parseClaims(token).get("role", String.class); + } + + public String validateToken(String token) { + try { + parseClaims(token); + return "valid"; + } catch (ExpiredJwtException e) { + logger.error("토큰의 유효기간이 만료되었습니다.", e); + return "expired"; + } catch (JwtException | IllegalArgumentException e) { + logger.error("유효하지 않은 토큰입니다.", e); + return "invalid"; + } + } +} diff --git a/backend/src/main/java/com/example/backend/global/auth/model/CustomUserDetails.java b/backend/src/main/java/com/example/backend/global/auth/model/CustomUserDetails.java new file mode 100644 index 00000000..6d917bee --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/auth/model/CustomUserDetails.java @@ -0,0 +1,53 @@ +package com.example.backend.global.auth.model; + +import com.example.backend.domain.member.entity.Member; +import java.util.Collection; +import java.util.Collections; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +@Getter +@RequiredArgsConstructor +public class CustomUserDetails implements UserDetails { + + private final Member member; + + @Override + public Collection getAuthorities() { + return Collections.singletonList(new SimpleGrantedAuthority(member.getRole().toString())); + } + + @Override + public String getPassword() { + return ""; + } + + @Override + public String getUsername() { + return member.getUsername(); + } + + @Override + public boolean isAccountNonExpired() { + return UserDetails.super.isAccountNonExpired(); + } + + @Override + public boolean isAccountNonLocked() { + return UserDetails.super.isAccountNonLocked(); + } + + @Override + public boolean isCredentialsNonExpired() { + return UserDetails.super.isCredentialsNonExpired(); + } + + @Override + public boolean isEnabled() { + return UserDetails.super.isEnabled(); + } + +} diff --git a/backend/src/main/java/com/example/backend/global/auth/service/AuthService.java b/backend/src/main/java/com/example/backend/global/auth/service/AuthService.java new file mode 100644 index 00000000..14ea154e --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/auth/service/AuthService.java @@ -0,0 +1,181 @@ +package com.example.backend.global.auth.service; + +import java.util.Map; +import java.util.UUID; + +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.example.backend.domain.common.EmailCertification; +import com.example.backend.domain.common.VerifyType; +import com.example.backend.domain.member.entity.Member; +import com.example.backend.domain.member.entity.MemberStatus; +import com.example.backend.domain.member.exception.MemberErrorCode; +import com.example.backend.domain.member.exception.MemberException; +import com.example.backend.domain.member.repository.MemberRepository; +import com.example.backend.global.auth.dto.AuthForm; +import com.example.backend.global.auth.dto.AuthResponse; +import com.example.backend.global.auth.exception.AuthErrorCode; +import com.example.backend.global.auth.exception.AuthException; +import com.example.backend.global.auth.jwt.JwtProvider; +import com.example.backend.global.auth.jwt.JwtUtils; +import com.example.backend.global.mail.service.MailService; +import com.example.backend.global.mail.util.TemplateName; +import com.example.backend.global.redis.service.RedisService; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional +public class AuthService { + private static final String REDIS_EMAIL_PREFIX = "certification_email:"; + private final JwtProvider jwtProvider; + private final JwtUtils jwtUtils; + private final PasswordEncoder passwordEncoder; + private final RefreshTokenService refreshTokenService; + private final MemberRepository memberRepository; + private final RedisService redisService; + private final MailService mailService; + private final ObjectMapper objectMapper; + + public AuthResponse login(AuthForm authForm) { + Member findMember = memberRepository.findByUsername(authForm.username()) + .orElseThrow(() -> new AuthException(AuthErrorCode.MEMBER_NOT_FOUND)); + + if (!passwordEncoder.matches(authForm.password(), findMember.getPassword())) { + throw new AuthException(AuthErrorCode.PASSWORD_NOT_MATCH); + } + + if (!MemberStatus.ACTIVE.equals(findMember.getMemberStatus())) { + throw new AuthException(AuthErrorCode.NOT_CERTIFICATION); + } + + String accessToken = jwtProvider.generateAccessToken(findMember.getId(), findMember.getUsername(), + findMember.getRole()); + String refreshToken = jwtProvider.generateRefreshToken(findMember.getId(), findMember.getUsername(), + findMember.getRole()); + refreshTokenService.saveRefreshToken(findMember.getUsername(), refreshToken); + + return AuthResponse.of(findMember.getUsername(), findMember.getNickname(), accessToken, refreshToken); + } + + public void logout(String accessToken) { + String username = jwtUtils.getUsernameFromToken(accessToken); + refreshTokenService.deleteRefreshToken(username); + + // 시큐리티 컨텍스트 초기화 + SecurityContextHolder.clearContext(); + } + + public void verify(String username, String certificationCode, VerifyType verifyType) { + Member findMember = memberRepository.findByUsername(username) + .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + + if (MemberStatus.ACTIVE.equals(findMember.getMemberStatus()) && VerifyType.SIGNUP.equals(verifyType)) { + throw new AuthException(AuthErrorCode.ALREADY_CERTIFIED); + } + + handleVerify(username, certificationCode, verifyType); + + // 비밀번호 초기화 코드면 인증 후 임시 비밀번호 발송 로직 추가할 것 + switch (verifyType) { + case SIGNUP -> { + findMember.verify(); + + memberRepository.save(findMember); + } + case PASSWORD_RESET -> { + String createTemporaryPassword = Member.createTemporaryPassword(); + findMember.changePassword(passwordEncoder.encode(createTemporaryPassword)); + memberRepository.save(findMember); + + mailService.sendTemporaryPasswordMail(username, createTemporaryPassword, TemplateName.PASSWORD_RESET); + } + } + } + + private void handleVerify(String username, String certificationCode, VerifyType verifyType) { + //인증 코드가 존재하지 않을 때 + Map getEmailCertification = redisService.getHashDataAll(REDIS_EMAIL_PREFIX + username); + + if (getEmailCertification.isEmpty()) { + throw new AuthException(AuthErrorCode.CERTIFICATION_CODE_NOT_FOUND); + } + + EmailCertification emailCertification = objectMapper.convertValue(getEmailCertification, + EmailCertification.class); + + //인증 타입이 일치하지 않을 때 + if (!emailCertification.getVerifyType().equalsIgnoreCase(verifyType.toString())) { + throw new AuthException(AuthErrorCode.VERIFY_TYPE_NOT_MATCH); + } + + //인증 코드가 일치하지 않을 때 + if (!emailCertification.getCertificationCode().equals(certificationCode)) { + throw new AuthException(AuthErrorCode.CERTIFICATION_CODE_NOT_MATCH); + } + + redisService.delete(REDIS_EMAIL_PREFIX + username); + } + + public void send(String username, VerifyType verifyType) { + TemplateName templateName = + VerifyType.SIGNUP.equals(verifyType) ? TemplateName.SIGNUP_VERIFY : TemplateName.PASSWORD_RESET_VERIFY; + + Member findMember = memberRepository.findByUsername(username) + .orElseThrow(() -> new AuthException(AuthErrorCode.MEMBER_NOT_FOUND)); + + if (VerifyType.SIGNUP.equals(verifyType) && MemberStatus.ACTIVE.equals(findMember.getMemberStatus())) { + throw new AuthException(AuthErrorCode.ALREADY_CERTIFIED); + } + + String certificationCode = UUID.randomUUID().toString(); + + Map findHashDataAll = redisService.getHashDataAll(REDIS_EMAIL_PREFIX + username); + + //인증 코드를 처음 발급하는지 확인 + if (findHashDataAll.isEmpty()) { + EmailCertification emailCertification = EmailCertification.builder() + .certificationCode(certificationCode) + .sendCount("1") + .verifyType(verifyType.toString()) + .build(); + + mailService.sendCertificationMail(username, emailCertification, templateName); + + redisService.setHashDataAll( + REDIS_EMAIL_PREFIX + username, objectMapper.convertValue(emailCertification, Map.class) + ); + + redisService.setTimeout(REDIS_EMAIL_PREFIX + username, 10); + } else { + EmailCertification findEmailCertification = objectMapper.convertValue(findHashDataAll, + EmailCertification.class); + + // 인증 코드 타입이 일치하지 않을 때 EXCEPTION + if (!verifyType.toString().equalsIgnoreCase(findEmailCertification.getVerifyType())) { + throw new AuthException(AuthErrorCode.VERIFY_TYPE_NOT_MATCH); + } + + //5회 이상 요청했을 때 EXCEPTION + if (Integer.parseInt(findEmailCertification.getSendCount()) >= 5) { + throw new AuthException(AuthErrorCode.TOO_MANY_RESEND_ATTEMPTS); + } + + findEmailCertification.addResendCount(); + findEmailCertification.setCertificationCode(certificationCode); + + mailService.sendCertificationMail(username, findEmailCertification, templateName); + + redisService.setHashDataAll( + REDIS_EMAIL_PREFIX + username, objectMapper.convertValue(findEmailCertification, Map.class) + ); + + redisService.setTimeout(REDIS_EMAIL_PREFIX + username, 10); + } + } +} diff --git a/backend/src/main/java/com/example/backend/global/auth/service/CookieService.java b/backend/src/main/java/com/example/backend/global/auth/service/CookieService.java new file mode 100644 index 00000000..d6c0edd3 --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/auth/service/CookieService.java @@ -0,0 +1,40 @@ +package com.example.backend.global.auth.service; + +import com.example.backend.global.auth.util.CookieUtils; +import com.example.backend.global.config.JwtConfig; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CookieService { + + private final CookieUtils cookieUtils; + private final JwtConfig jwtConfig; + + public String getAccessTokenFromRequest(HttpServletRequest request) { + return cookieUtils.getTokenFromRequest(request, "accessToken"); + } + + public String getRefreshTokenFromRequest(HttpServletRequest request) { + return cookieUtils.getTokenFromRequest(request, "refreshToken"); + } + + public void addAccessTokenToCookie(String accessToken, HttpServletResponse response) { + cookieUtils.addTokenToCookie("accessToken", accessToken, jwtConfig.getAccessTokenExpirationTimeInSeconds(), response); + } + + public void addRefreshTokenToCookie(String refreshToken, HttpServletResponse response) { + cookieUtils.addTokenToCookie("refreshToken", refreshToken, jwtConfig.getRefreshTokenExpirationTimeInSeconds(), response); + } + + public void deleteAccessTokenFromCookie(HttpServletResponse response) { + cookieUtils.addTokenToCookie("accessToken", null, 0L, response); + } + + public void deleteRefreshTokenFromCookie(HttpServletResponse response) { + cookieUtils.addTokenToCookie("refreshToken", null, 0L, response); + } +} diff --git a/backend/src/main/java/com/example/backend/global/auth/service/CustomUserDetailsService.java b/backend/src/main/java/com/example/backend/global/auth/service/CustomUserDetailsService.java new file mode 100644 index 00000000..96ea79ad --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/auth/service/CustomUserDetailsService.java @@ -0,0 +1,24 @@ +package com.example.backend.global.auth.service; + +import com.example.backend.domain.member.entity.Member; +import com.example.backend.domain.member.repository.MemberRepository; +import com.example.backend.global.auth.model.CustomUserDetails; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final MemberRepository memberRepository; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + Member member = memberRepository.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException(username)); + return new CustomUserDetails(member); + } +} diff --git a/backend/src/main/java/com/example/backend/global/auth/service/RefreshTokenService.java b/backend/src/main/java/com/example/backend/global/auth/service/RefreshTokenService.java new file mode 100644 index 00000000..4a5e7acf --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/auth/service/RefreshTokenService.java @@ -0,0 +1,31 @@ +package com.example.backend.global.auth.service; + +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import com.example.backend.global.redis.service.RedisService; + +@Service +@RequiredArgsConstructor +public class RefreshTokenService { + + private final RedisService redisService; + + public void saveRefreshToken(String username, String refreshToken) { + //timeout은 분 기준으로 추가 + redisService.setData(username, refreshToken, 10080); + } + + public String getRefreshToken(String username) { + return redisService.getData(username); + } + + public void deleteRefreshToken(String username) { + redisService.delete(username); + } + + public boolean isValidRefreshToken(String username, String refreshToken) { + return Objects.equals(getRefreshToken(username), refreshToken); + } +} diff --git a/backend/src/main/java/com/example/backend/global/auth/util/CookieUtils.java b/backend/src/main/java/com/example/backend/global/auth/util/CookieUtils.java new file mode 100644 index 00000000..969eb915 --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/auth/util/CookieUtils.java @@ -0,0 +1,35 @@ +package com.example.backend.global.auth.util; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; + +@Component +public class CookieUtils { + + public String getTokenFromRequest(HttpServletRequest request, String type) { + Cookie[] cookies = request.getCookies(); + if (cookies != null) { + for (Cookie cookie : cookies) { + if (type.equals(cookie.getName())) { + return cookie.getValue(); + } + } + } + return null; + } + + public void addTokenToCookie(String type, String token, Long expirationTime, HttpServletResponse response) { + ResponseCookie cookie = ResponseCookie.from(type, token) + .httpOnly(true) + .secure(true) + .path("/") + .sameSite("Strict") + .maxAge(expirationTime) + .build(); + + response.addHeader("Set-Cookie", cookie.toString()); + } +} diff --git a/backend/src/main/java/com/example/backend/global/auth/util/FilterUtils.java b/backend/src/main/java/com/example/backend/global/auth/util/FilterUtils.java new file mode 100644 index 00000000..662739f7 --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/auth/util/FilterUtils.java @@ -0,0 +1,80 @@ +package com.example.backend.global.auth.util; + +import java.io.IOException; +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.example.backend.global.auth.exception.AuthErrorCode; +import com.example.backend.global.response.HttpErrorInfo; +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class FilterUtils { + + private final ObjectMapper objectMapper; + + // 보호되지 않는 특정 URL 목록 + private static final List UNPROTECTED_URLS = List.of( + "/api/v1/members/join", + "/api/v1/auth/login", + "/api/v1/auth/code", + "/api/v1/auth/verify" + ); + + // 보호된 URL 패턴 목록 + private static final List PROTECTED_URLS = List.of( + "/api/v1/members", + "/api/v1/auth", + "/api/v1/products", + "/api/v1/orders", + "/api/v1/carts" + ); + + //필터에서 예외 발생 시 에러 메세지 생성 후 응답객체에 추가하는 메서드 + public void createErrorInfo(AuthErrorCode authErrorCode, HttpServletRequest request, + HttpServletResponse response) throws IOException { + HttpErrorInfo httpErrorInfo = HttpErrorInfo.of( + authErrorCode.getCode(), + request.getRequestURI(), + authErrorCode.getMessage() + ); + response.setCharacterEncoding("UTF-8"); + response.setContentType("application/json"); + response.getWriter().write(objectMapper.writeValueAsString(httpErrorInfo)); + response.setStatus(authErrorCode.getHttpStatus().value()); + } + + // 권한 체크가 필요 없는 URL인지 확인하는 메서드 + public boolean isUnprotectedUrl(HttpServletRequest request) { + String path = request.getRequestURI(); + String method = request.getMethod(); + + // GET 메서드인 경우 + if ("GET".equalsIgnoreCase(method)) { + // GET 요청은 보호되지 않는 URL 목록에 있는지 확인 + if ("/api/v1/products".equals(path)) { + return true; // GET 요청은 보호되지 않는 URL로 간주 + } + } + + // 보호되지 않는 URL 패턴 예외 처리 + if (UNPROTECTED_URLS.contains(path)) { + return true; // 보호되지 않는 URL로 간주 + } + + // 권한이 필요한 URL 패턴에 매칭되는지 확인 + for (String protectedUrl : PROTECTED_URLS) { + if (path.startsWith(protectedUrl)) { + return false; // 권한이 필요한 URL이면 false 반환 + } + } + + return true; // 나머지 URL은 모두 허용 + } +} diff --git a/backend/src/main/java/com/example/backend/global/baseEntity/BaseEntity.java b/backend/src/main/java/com/example/backend/global/baseEntity/BaseEntity.java new file mode 100644 index 00000000..3e7188a1 --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/baseEntity/BaseEntity.java @@ -0,0 +1,48 @@ +package com.example.backend.global.baseEntity; + +import java.time.LocalDateTime; +import java.time.ZonedDateTime; + +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import lombok.Getter; + +/** + * BaseEntity + *

엔티티 생성, 수정 일자를 관리하는 BaseEntity 입니다.

+ * @author Kim Dong O + */ +@Getter +@MappedSuperclass +public abstract class BaseEntity { + + /** + * 생성일시 + */ + @Column(name = "created_at") + protected ZonedDateTime createdAt; + + /** + * 수정일시 + */ + @Column(name = "modified_at") + protected ZonedDateTime modifiedAt; + + @PrePersist + public void prePersist() { + this.createdAt = ZonedDateTime.now(); + this.modifiedAt = ZonedDateTime.now(); + } + + @PreUpdate + public void preUpdate() { + this.modifiedAt = ZonedDateTime.now(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/example/backend/global/config/AsyncConfig.java b/backend/src/main/java/com/example/backend/global/config/AsyncConfig.java new file mode 100644 index 00000000..85fe88b8 --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/config/AsyncConfig.java @@ -0,0 +1,28 @@ +package com.example.backend.global.config; + +import java.util.concurrent.Executor; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +@Configuration +@EnableAsync +public class AsyncConfig { + + @Bean(name = "threadPoolTaskExecutor") + public Executor getAsyncExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + //스레드 풀 기본 사이즈 설정 + executor.setCorePoolSize(3); + //대기열이 가득차면 추가로 사용할 스레드 최대 사이즈 설정 + executor.setMaxPoolSize(10); + //corePoolSize가 가득 찬 상태에서 대기시킬 작업 개수 + executor.setQueueCapacity(500); + //스레드 prefix + executor.setThreadNamePrefix("Executor-"); + executor.initialize(); + return executor; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/example/backend/global/config/CorsConfig.java b/backend/src/main/java/com/example/backend/global/config/CorsConfig.java new file mode 100644 index 00000000..70fe3038 --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/config/CorsConfig.java @@ -0,0 +1,24 @@ +package com.example.backend.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; + +@Configuration +public class CorsConfig { + + @Bean + public CorsFilter corsFilter() { + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + CorsConfiguration config = new CorsConfiguration(); + config.setAllowCredentials(true); // 클라이언트가 자격 증명을 함께 전송할 수 있도록 허용 + config.addAllowedOrigin("http://localhost:3000"); // 클라이언트 도메인 + config.addAllowedHeader("*"); // 서버에서 클라이언트로 보낼때 모든 헤더를 허용 + config.addExposedHeader("Authorization"); // 클라이언트에서 Authorization 헤더에 접근 허용 + config.addAllowedMethod("*"); // 모든 메서드 허용 + source.registerCorsConfiguration("/**", config); + return new CorsFilter(source); + } +} diff --git a/backend/src/main/java/com/example/backend/global/config/JpaAuditingConfig.java b/backend/src/main/java/com/example/backend/global/config/JpaAuditingConfig.java new file mode 100644 index 00000000..7b88c252 --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/config/JpaAuditingConfig.java @@ -0,0 +1,14 @@ +package com.example.backend.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +/** + * JpaAuditingConfig + *

JpaAuditing를 활성화하는 설정 클래스 입니다.

+ * @author Kim Dong O + */ +@Configuration +@EnableJpaAuditing +public class JpaAuditingConfig { +} \ No newline at end of file diff --git a/backend/src/main/java/com/example/backend/global/config/JwtConfig.java b/backend/src/main/java/com/example/backend/global/config/JwtConfig.java new file mode 100644 index 00000000..b8e949de --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/config/JwtConfig.java @@ -0,0 +1,45 @@ +package com.example.backend.global.config; + +import io.jsonwebtoken.security.Keys; +import java.security.Key; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class JwtConfig { + + private final Key secretKey; + private final long accessTokenExpirationTime; + private final long refreshTokenExpirationTime; + + @Autowired + public JwtConfig(@Value("${JWT_SECRET}") String secret, + @Value("${JWT_ACCESS_TOKEN_EXPIRATION_TIME}") long accessTokenExpirationTime, + @Value("${JWT_REFRESH_TOKEN_EXPIRATION_TIME}") long refreshTokenExpirationTime) { + this.accessTokenExpirationTime = accessTokenExpirationTime; + this.refreshTokenExpirationTime = refreshTokenExpirationTime; + this.secretKey = Keys.hmacShaKeyFor(secret.getBytes()); + } + + public Key getSecretKey() { + return this.secretKey; + } + + public long getAccessTokenExpirationTime() { + return this.accessTokenExpirationTime; + } + + public long getRefreshTokenExpirationTime() { + return this.refreshTokenExpirationTime; + } + + // 쿠키의 유효기간을 초 단위로 변환 + public long getAccessTokenExpirationTimeInSeconds() { + return (accessTokenExpirationTime / 1000) * 2; + } + + public long getRefreshTokenExpirationTimeInSeconds() { + return refreshTokenExpirationTime / 1000; + } +} diff --git a/backend/src/main/java/com/example/backend/global/config/MailConfig.java b/backend/src/main/java/com/example/backend/global/config/MailConfig.java new file mode 100644 index 00000000..a65953e7 --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/config/MailConfig.java @@ -0,0 +1,108 @@ +package com.example.backend.global.config; + +import java.util.Map; +import java.util.Properties; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.JavaMailSenderImpl; +import org.thymeleaf.spring6.SpringTemplateEngine; +import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver; +import org.thymeleaf.templateresolver.ITemplateResolver; + +import com.example.backend.global.mail.util.EmailTemplateMaker; +import com.example.backend.global.mail.util.TemplateMaker; +import com.example.backend.global.mail.util.TemplateName; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Configuration +@Slf4j +@Getter +@RequiredArgsConstructor +public class MailConfig { + + @Value("${mail.host}") + private String mailHost; + @Value("${mail.port}") + private int mailPort; + @Value("${mail.username}") + private String mailUsername; + @Value("${mail.password}") + private String mailPassword; + @Value("${mail.properties.mail.smtp.auth}") + private boolean smtpAuth; + + @Value("${mail.properties.mail.smtp.starttls.enable}") + private boolean smtpStartTlsEnable; + + @Value("${mail.templates.path}") + private String templatesPath; + + @Value("${mail.templates.password-reset}") + private String passwordReset; + + @Value("${mail.templates.email-verify}") + private String emailVerify; + + @Value("${mail.templates.signup-verify}") + private String signupVerify; + + @Value("${mail.templates.delivery-start}") + private String deliveryStart; + + + @Bean + public JavaMailSender mailSender() { + JavaMailSenderImpl mailSender = new JavaMailSenderImpl(); + mailSender.setHost(mailHost); + mailSender.setPort(mailPort); + mailSender.setUsername(mailUsername); + mailSender.setPassword(mailPassword); + Properties props = System.getProperties(); + props.put("mail.transport.protocol", "smtp"); + props.put("mail.smtp.starttls.enable", smtpStartTlsEnable); + mailSender.setJavaMailProperties(props); + return mailSender; + } + + @Bean + public TemplateMaker emailTemplateMaker() { + Map templateNameMap = new ConcurrentHashMap<>(); + + //각 템플릿 이름 Map에 저장 + templateNameMap.put(TemplateName.PASSWORD_RESET.toString(), passwordReset); + templateNameMap.put(TemplateName.PASSWORD_RESET_VERIFY.toString(), emailVerify); + templateNameMap.put(TemplateName.SIGNUP_VERIFY.toString(), signupVerify); + templateNameMap.put(TemplateName.DELIVERY_START.toString(), deliveryStart); + + EmailTemplateMaker emailTemplateMaker = new EmailTemplateMaker( + thymeleafTemplateEngine(), + templateNameMap + ); + + return emailTemplateMaker; + } + + @Bean + public SpringTemplateEngine thymeleafTemplateEngine() { + SpringTemplateEngine templateEngine = new SpringTemplateEngine(); + templateEngine.setTemplateResolver(thymeleafTemplateResolver()); + return templateEngine; + } + + @Bean + public ITemplateResolver thymeleafTemplateResolver() { + ClassLoaderTemplateResolver templateResolver = new ClassLoaderTemplateResolver(); + templateResolver.setPrefix(templatesPath); + templateResolver.setSuffix(".html"); + templateResolver.setTemplateMode("HTML"); + templateResolver.setCharacterEncoding("UTF-8"); + return templateResolver; + } +} diff --git a/backend/src/main/java/com/example/backend/global/config/RedisConfig.java b/backend/src/main/java/com/example/backend/global/config/RedisConfig.java new file mode 100644 index 00000000..0054baf1 --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/config/RedisConfig.java @@ -0,0 +1,25 @@ +package com.example.backend.global.config; + +import java.nio.charset.StandardCharsets; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory factory) { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(factory); + redisTemplate.setKeySerializer(new StringRedisSerializer(StandardCharsets.UTF_8)); + redisTemplate.setValueSerializer(new StringRedisSerializer(StandardCharsets.UTF_8)); + redisTemplate.setHashKeySerializer(new StringRedisSerializer(StandardCharsets.UTF_8)); + redisTemplate.setHashValueSerializer(new StringRedisSerializer(StandardCharsets.UTF_8)); + redisTemplate.setEnableTransactionSupport(true); + return redisTemplate; + } +} diff --git a/backend/src/main/java/com/example/backend/global/config/SecurityConfig.java b/backend/src/main/java/com/example/backend/global/config/SecurityConfig.java new file mode 100644 index 00000000..f13491eb --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/config/SecurityConfig.java @@ -0,0 +1,70 @@ +package com.example.backend.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +import com.example.backend.global.auth.filter.JwtAuthorizationFilter; +import com.example.backend.global.auth.filter.RefreshTokenFilter; +import com.example.backend.global.auth.jwt.JwtProvider; +import com.example.backend.global.auth.jwt.JwtUtils; +import com.example.backend.global.auth.service.CookieService; +import com.example.backend.global.auth.service.CustomUserDetailsService; +import com.example.backend.global.auth.service.RefreshTokenService; +import com.example.backend.global.auth.util.FilterUtils; + +import lombok.RequiredArgsConstructor; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +@Profile("!test") +public class SecurityConfig { + + private final CorsConfig corsConfig; + private final JwtProvider jwtProvider; + private final JwtUtils jwtUtils; + private final FilterUtils filterUtils; + private final CookieService cookieService; + private final RefreshTokenService refreshTokenService; + private final CustomUserDetailsService customUserDetailsService; + + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy( + SessionCreationPolicy.STATELESS)) + .addFilter(corsConfig.corsFilter()) + .authorizeHttpRequests(authorizeRequests -> authorizeRequests + .requestMatchers(HttpMethod.GET, "/api/v1/products").permitAll() + .requestMatchers("/api/v1/members/join", "/api/v1/auth/verify", "/api/v1/auth/login", "/api/v1/auth/code").permitAll() + .requestMatchers(HttpMethod.GET, "/api/v1/products/{id}").hasAnyRole("USER", "ADMIN") + .requestMatchers("/api/v1/members/**").hasAnyRole("USER", "ADMIN") + .requestMatchers("/api/v1/auth/**").hasAnyRole("USER", "ADMIN") + .requestMatchers("/api/v1/products/**").hasAnyRole("ADMIN") + .requestMatchers("/api/v1/orders/**").hasAnyRole("USER", "ADMIN") + .requestMatchers("/api/v1/carts/**").hasAnyRole("USER", "ADMIN") + .anyRequest().permitAll()) + .addFilterBefore(new JwtAuthorizationFilter(jwtUtils, filterUtils, cookieService, customUserDetailsService), + UsernamePasswordAuthenticationFilter.class) + .addFilterAfter(new RefreshTokenFilter(jwtProvider, jwtUtils, filterUtils, cookieService, + refreshTokenService, customUserDetailsService), JwtAuthorizationFilter.class); + return http.build(); + } + + @Bean + PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/backend/src/main/java/com/example/backend/global/config/SpringDocConfig.java b/backend/src/main/java/com/example/backend/global/config/SpringDocConfig.java new file mode 100644 index 00000000..1675c1e3 --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/config/SpringDocConfig.java @@ -0,0 +1,64 @@ +package com.example.backend.global.config; + +import org.springdoc.core.models.GroupedOpenApi; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.security.SecurityScheme; + +@Configuration +@OpenAPIDefinition(info = @Info(title = "Team8 1차 프로젝트", version = "v1")) +@SecurityScheme(name = "bearerAuth", type = SecuritySchemeType.HTTP, bearerFormat = "JWT", scheme = "bearer") +public class SpringDocConfig { + + @Bean + public GroupedOpenApi api() { + return GroupedOpenApi.builder() + .group("apiV1") + .pathsToMatch("/api/**") + .build(); + } + + @Bean + public GroupedOpenApi groupApiMembers() { + return GroupedOpenApi.builder() + .group("members") + .pathsToMatch("/api/v1/members/**") + .build(); + } + + @Bean + public GroupedOpenApi groupApiProducts() { + return GroupedOpenApi.builder() + .group("products") + .pathsToMatch("/api/v1/products/**") + .build(); + } + + @Bean + public GroupedOpenApi groupApiOrders() { + return GroupedOpenApi.builder() + .group("orders") + .pathsToMatch("/api/v1/orders/**") + .build(); + } + + @Bean + public GroupedOpenApi groupApiCarts() { + return GroupedOpenApi.builder() + .group("carts") + .pathsToMatch("/api/v1/carts/**") + .build(); + } + + @Bean + public GroupedOpenApi groupApiAuth() { + return GroupedOpenApi.builder() + .group("auth") + .pathsToMatch("/api/v1/auth/**") + .build(); + } +} diff --git a/backend/src/main/java/com/example/backend/global/exception/GlobalErrorCode.java b/backend/src/main/java/com/example/backend/global/exception/GlobalErrorCode.java new file mode 100644 index 00000000..7d981cc5 --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/exception/GlobalErrorCode.java @@ -0,0 +1,26 @@ +package com.example.backend.global.exception; + +import org.springframework.http.HttpStatus; + +import lombok.Getter; + +/** + * GlobalErrorCode + *

Global 예외 발생시 예외 코드를 정의하는 Enum 클래스 입니다.

+ * + * @author Kim Dong O + */ +@Getter +public enum GlobalErrorCode { + NOT_VALID(HttpStatus.BAD_REQUEST, "400-1", "요청하신 유효성 검증에 실패하였습니다."); + + final HttpStatus httpStatus; + final String code; + final String message; + + GlobalErrorCode(HttpStatus httpStatus, String code, String message) { + this.httpStatus = httpStatus; + this.code = code; + this.message = message; + } +} diff --git a/backend/src/main/java/com/example/backend/global/exception/GlobalException.java b/backend/src/main/java/com/example/backend/global/exception/GlobalException.java new file mode 100644 index 00000000..fcd02c5d --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/exception/GlobalException.java @@ -0,0 +1,20 @@ +package com.example.backend.global.exception; + +import org.springframework.http.HttpStatus; + +public class GlobalException extends RuntimeException { + private final GlobalErrorCode globalErrorCode; + + public GlobalException(GlobalErrorCode globalErrorCode) { + super(globalErrorCode.message); + this.globalErrorCode = globalErrorCode; + } + + public HttpStatus getStatus() { + return globalErrorCode.httpStatus; + } + + public String getCode() { + return globalErrorCode.code; + } +} diff --git a/backend/src/main/java/com/example/backend/global/mail/service/MailService.java b/backend/src/main/java/com/example/backend/global/mail/service/MailService.java new file mode 100644 index 00000000..fad1a97a --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/mail/service/MailService.java @@ -0,0 +1,34 @@ +package com.example.backend.global.mail.service; + +import java.util.List; + +import com.example.backend.domain.common.EmailCertification; +import com.example.backend.global.mail.util.TemplateName; + +/** + * MailService + *

메일 전송 서비스 인터페이스 입니다.

+ * @author Kim Dong O + */ +public interface MailService { + /** + * @implSpec 비동기로 이메일을 전송 합니다. + * @param to 받는 사람 이메일 + * @param emailCertification 이메일 인증 객체 + * @param templateName 템플릿 이름 + */ + void sendCertificationMail(String to, EmailCertification emailCertification, TemplateName templateName); + /** + * @implSpec 비동기로 이메일을 전송 합니다. + * @param to 받는 사람 이메일 + * @param temporaryPassword 임시 비밀번호 + * @param templateName 템플릿 이름 + */ + void sendTemporaryPasswordMail(String to, String temporaryPassword, TemplateName templateName); + /** + * @implSpec 비동기로 다수의 회원에게 이메일을 전송 합니다. + * @param to 받는 사람 이메일들의 이메일 + * @param templateName 템플릿 이름 + */ + void sendDeliveryStartEmail(List to, TemplateName templateName); +} diff --git a/backend/src/main/java/com/example/backend/global/mail/service/MailServiceImpl.java b/backend/src/main/java/com/example/backend/global/mail/service/MailServiceImpl.java new file mode 100644 index 00000000..667c5c37 --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/mail/service/MailServiceImpl.java @@ -0,0 +1,98 @@ +package com.example.backend.global.mail.service; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import com.example.backend.domain.common.EmailCertification; +import com.example.backend.global.mail.util.MailSender; +import com.example.backend.global.mail.util.TemplateMaker; +import com.example.backend.global.mail.util.TemplateName; + +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class MailServiceImpl implements MailService { + @Value("${mail.verify-url}") + private String verifyUrl; + private final TemplateMaker templateMaker; + private final MailSender mailSender; + + @Async("threadPoolTaskExecutor") + @Override + public void sendCertificationMail(String to, EmailCertification emailCertification, TemplateName templateName) { + StringBuilder titleBuilder = new StringBuilder(); + Map htmlParameterMap = new HashMap<>(); + switch (templateName) { + case TemplateName.PASSWORD_RESET_VERIFY -> { + titleBuilder.append("[TEAM8] 비밀번호 초기화 인증번호 입니다."); + htmlParameterMap.put("certificationCode", emailCertification.getCertificationCode()); + } + case TemplateName.SIGNUP_VERIFY -> { + titleBuilder.append("[TEAM8] 이메일 인증 메일 입니다."); + + String certificationUrl = generateCertificationUrl(to, emailCertification.getCertificationCode(), + emailCertification.getVerifyType()); + + htmlParameterMap.put("certificationUrl", certificationUrl); + + } + } + + String title = titleBuilder.toString(); + + MimeMessage mimeMessage = templateMaker.create(mailSender.createMimeMessage(), to, title, htmlParameterMap, + templateName); + + mailSender.send(mimeMessage); + } + + @Async("threadPoolTaskExecutor") + @Override + public void sendTemporaryPasswordMail(String to, String temporaryPassword, TemplateName templateName) { + StringBuilder titleBuilder = new StringBuilder(); + Map htmlParameterMap = new HashMap<>(); + switch (templateName) { + case TemplateName.PASSWORD_RESET -> { + titleBuilder.append("[TEAM8] 임시 비밀번호 입니다."); + htmlParameterMap.put("temporaryPassword", temporaryPassword); + } + } + + String title = titleBuilder.toString(); + + MimeMessage mimeMessage = templateMaker.create(mailSender.createMimeMessage(), to, title, htmlParameterMap, + templateName); + + mailSender.send(mimeMessage); + } + + @Async("threadPoolTaskExecutor") + @Override + public void sendDeliveryStartEmail(List to, TemplateName templateName) { + StringBuilder titleBuilder = new StringBuilder(); + + switch (templateName) { + case TemplateName.DELIVERY_START -> { + titleBuilder.append("[TEAM8] 배송 시작 메일 입니다."); + } + } + + String title = titleBuilder.toString(); + + MimeMessage mimeMessage = templateMaker.create(mailSender.createMimeMessage(), to, title, templateName); + + mailSender.send(mimeMessage); + } + + private String generateCertificationUrl(String to, String certificationCode, String verifyType) { + return verifyUrl + "username=" + to + "&certificationCode=" + + certificationCode + "&verifyType=" + verifyType; + } +} diff --git a/backend/src/main/java/com/example/backend/global/mail/util/EmailTemplateMaker.java b/backend/src/main/java/com/example/backend/global/mail/util/EmailTemplateMaker.java new file mode 100644 index 00000000..0ec9e9eb --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/mail/util/EmailTemplateMaker.java @@ -0,0 +1,78 @@ +package com.example.backend.global.mail.util; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.mail.javamail.MimeMessageHelper; +import org.thymeleaf.context.Context; +import org.thymeleaf.spring6.SpringTemplateEngine; + +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import lombok.extern.slf4j.Slf4j; + +/** + * TemplateMaker 구현체 입니다. + *

이메일 템플릿을 만들어 반환합니다.

+ * @author : Kim Dong O + */ +@Slf4j +public class EmailTemplateMaker implements TemplateMaker { + + private final SpringTemplateEngine templateEngine; + private Map templateNameMap = new ConcurrentHashMap<>(); + + public EmailTemplateMaker(SpringTemplateEngine templateEngine, Map templateNameMap) { + this.templateEngine = templateEngine; + this.templateNameMap = templateNameMap; + } + + @Override + public MimeMessage create(MimeMessage newMimeMessage, String username, String title, + Map htmlParameterMap, TemplateName templateName) { + try { + MimeMessageHelper helper = new MimeMessageHelper(newMimeMessage, true, "UTF-8"); + + Context context = new Context(); + + //파라미터 값 설정 + htmlParameterMap.forEach(context::setVariable); + + String processedHtmlContent = templateEngine.process(templateNameMap.get(templateName.toString()), context); + log.info("processedHtmlContent = {}", processedHtmlContent); + + helper.setTo(username); + helper.setSubject(title); + helper.setText(processedHtmlContent, true); + + } catch (MessagingException e) { + throw new RuntimeException(e); + } + + return newMimeMessage; + } + + @Override + public MimeMessage create(MimeMessage newMimeMessage, List usernameList, String title, TemplateName templateName) { + try { + MimeMessageHelper helper = new MimeMessageHelper(newMimeMessage, true, "UTF-8"); + + Context context = new Context(); + + String[] emailArray = usernameList.toArray(new String[0]); + + String processedHtmlContent = templateEngine.process(templateNameMap.get(templateName.toString()), context); + log.info("processedHtmlContent = {}", processedHtmlContent); + + helper.setTo(emailArray); + helper.setSubject(title); + helper.setText(processedHtmlContent, true); + + } catch (MessagingException e) { + throw new RuntimeException(e); + } + + return newMimeMessage; + } +} diff --git a/backend/src/main/java/com/example/backend/global/mail/util/MailSender.java b/backend/src/main/java/com/example/backend/global/mail/util/MailSender.java new file mode 100644 index 00000000..7a9da9ca --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/mail/util/MailSender.java @@ -0,0 +1,22 @@ +package com.example.backend.global.mail.util; + +import jakarta.mail.internet.MimeMessage; + +/** + * MailSender + *

메일 전송 기능 인터페이스 입니다.

+ * @author Kim Dong O + */ +public interface MailSender { + /** + * @implSpec {@link MimeMessage}를 받아 메일 전송을 합니다. + * @param mimeMessage + */ + public void send(MimeMessage mimeMessage); + + /** + * @implSpec 빈 MimeMessage를 생성해 반환합니다. + * @return {@link MimeMessage} + */ + public MimeMessage createMimeMessage(); +} diff --git a/backend/src/main/java/com/example/backend/global/mail/util/MailSenderImpl.java b/backend/src/main/java/com/example/backend/global/mail/util/MailSenderImpl.java new file mode 100644 index 00000000..be3b6680 --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/mail/util/MailSenderImpl.java @@ -0,0 +1,28 @@ +package com.example.backend.global.mail.util; + +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.stereotype.Component; + +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; + +/** + * MailSender + *

MailSender 구현체 입니다.

+ * @author Kim Dong O + */ +@Component +@RequiredArgsConstructor +public class MailSenderImpl implements MailSender { + private final JavaMailSender javaMailSender; + + @Override + public void send(MimeMessage mimeMessage) { + javaMailSender.send(mimeMessage); + } + + @Override + public MimeMessage createMimeMessage() { + return javaMailSender.createMimeMessage(); + } +} diff --git a/backend/src/main/java/com/example/backend/global/mail/util/TemplateMaker.java b/backend/src/main/java/com/example/backend/global/mail/util/TemplateMaker.java new file mode 100644 index 00000000..63122014 --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/mail/util/TemplateMaker.java @@ -0,0 +1,36 @@ +package com.example.backend.global.mail.util; + +import java.util.List; +import java.util.Map; + +import jakarta.mail.internet.MimeMessage; + +/** + * TemplateMaker 인터페이스 입니다. + *

이메일 템플릿을 만들어 반환합니다.

+ * @author Kim Dong O + */ +public interface TemplateMaker { + + /** + * @implSpec MimeMessage에 사용할 템플릿, 변수 등을 설정하여 반환합니다. + * @param newMimeMessage + * @param username + * @param title + * @param htmlParameterMap + * @param templateName + * @return {@link MimeMessage} + */ + MimeMessage create(MimeMessage newMimeMessage, String username, String title, + Map htmlParameterMap, TemplateName templateName); + + /** + * @implSpec 파라미터 값이 없는 메일을 전송할 때 사용하며 템플릿, 타이틀을 설정하여 반환합니다. + * @param newMimeMessage + * @param usernameList + * @param title + * @param templateName + * @return {@link MimeMessage} + */ + MimeMessage create(MimeMessage newMimeMessage, List usernameList, String title, TemplateName templateName); +} diff --git a/backend/src/main/java/com/example/backend/global/mail/util/TemplateName.java b/backend/src/main/java/com/example/backend/global/mail/util/TemplateName.java new file mode 100644 index 00000000..3073fa03 --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/mail/util/TemplateName.java @@ -0,0 +1,10 @@ +package com.example.backend.global.mail.util; + +/** + * TemplateName + *

템플릿 타입을 정의해두는 Enum 입니다.

+ * @author Kim Dong O + */ +public enum TemplateName { + SIGNUP_VERIFY, PASSWORD_RESET, PASSWORD_RESET_VERIFY, DELIVERY_START; +} diff --git a/backend/src/main/java/com/example/backend/global/redis/RedisDao.java b/backend/src/main/java/com/example/backend/global/redis/RedisDao.java new file mode 100644 index 00000000..7d48b037 --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/redis/RedisDao.java @@ -0,0 +1,24 @@ +package com.example.backend.global.redis; + +import java.util.Map; + +public interface RedisDao { + void setData(String key, String data, long timeout); + + String getData(String key); + + void delete(String key); + + void setHashDataAll(String key, Map map); + + Map getHashDataAll(String key); + + String getHashData(String key, String hashKey); + + void setHashData(String key, String hashKey, String data); + + void setTimeout(String key, long timeout); + + Boolean hasKey(String key); + +} diff --git a/backend/src/main/java/com/example/backend/global/redis/RedisDaoImpl.java b/backend/src/main/java/com/example/backend/global/redis/RedisDaoImpl.java new file mode 100644 index 00000000..56c0f08a --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/redis/RedisDaoImpl.java @@ -0,0 +1,70 @@ +package com.example.backend.global.redis; + +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import org.springframework.data.redis.core.HashOperations; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Component +@Slf4j +@RequiredArgsConstructor +public class RedisDaoImpl implements RedisDao { + private final RedisTemplate redisTemplate; + + @Override + public void setData(String key, String data, long timeout) { + ValueOperations value = redisTemplate.opsForValue(); + value.set(key, data, timeout, TimeUnit.MINUTES); + } + + @Override + public String getData(String key) { + ValueOperations value = redisTemplate.opsForValue(); + return (String)value.get(key); + } + + @Override + public void delete(String key) { + redisTemplate.delete(key); + } + + @Override + public void setHashDataAll(String key, Map map) { + HashOperations hash = redisTemplate.opsForHash(); + for (Object o : map.keySet()) { + log.info("redisDao={}", map.get(o)); + } + hash.putAll(key, map); + } + + @Override + public Map getHashDataAll(String key) { + return redisTemplate.opsForHash().entries(key); + } + + @Override + public String getHashData(String key, String hashKey) { + return (String)redisTemplate.opsForHash().get(key, hashKey); + } + + @Override + public void setHashData(String key, String hashKey, String data) { + redisTemplate.opsForHash().put(key, hashKey, data); + } + + @Override + public void setTimeout(String key, long timeout) { + redisTemplate.expire(key, timeout, TimeUnit.MINUTES); + } + + @Override + public Boolean hasKey(String key) { + return redisTemplate.hasKey(key); + } +} diff --git a/backend/src/main/java/com/example/backend/global/redis/service/RedisService.java b/backend/src/main/java/com/example/backend/global/redis/service/RedisService.java new file mode 100644 index 00000000..ff04102c --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/redis/service/RedisService.java @@ -0,0 +1,23 @@ +package com.example.backend.global.redis.service; + +import java.util.Map; + +public interface RedisService { + public void setData(String key, String data, long timeout); + + public String getData(String key); + + public void delete(String key); + + void setHashDataAll(String key, Map map); + + Map getHashDataAll(String key); + + String getHashData(String key, String hashKey); + + void setHashData(String key, String hashKey, String data); + + void setTimeout(String key, long timeout); + + Boolean hasKey(String key); +} diff --git a/backend/src/main/java/com/example/backend/global/redis/service/RedisServiceImpl.java b/backend/src/main/java/com/example/backend/global/redis/service/RedisServiceImpl.java new file mode 100644 index 00000000..d8e640d2 --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/redis/service/RedisServiceImpl.java @@ -0,0 +1,61 @@ +package com.example.backend.global.redis.service; + +import java.util.Map; + +import org.springframework.stereotype.Component; + +import com.example.backend.global.redis.RedisDao; + +@Component +public class RedisServiceImpl implements RedisService { + private final RedisDao redisDao; + + public RedisServiceImpl(RedisDao redisDao) { + this.redisDao = redisDao; + } + + @Override + public void setData(String key, String data, long timeout) { + redisDao.setData(key, data, timeout); + } + + @Override + public String getData(String key) { + return redisDao.getData(key); + } + + @Override + public void delete(String key) { + redisDao.delete(key); + } + + @Override + public void setHashDataAll(String key, Map map) { + redisDao.setHashDataAll(key, map); + } + + @Override + public Map getHashDataAll(String key) { + return redisDao.getHashDataAll(key); + } + + @Override + public String getHashData(String key, String hashKey) { + return redisDao.getHashData(key, hashKey); + } + + @Override + public void setHashData(String key, String hashKey, String data) { + redisDao.setHashData(key, hashKey, data); + } + + @Override + public void setTimeout(String key, long timeout) { + redisDao.setTimeout(key, timeout); + } + + @Override + public Boolean hasKey(String key) { + return redisDao.hasKey(key); + } +} diff --git a/backend/src/main/java/com/example/backend/global/response/ErrorDetail.java b/backend/src/main/java/com/example/backend/global/response/ErrorDetail.java new file mode 100644 index 00000000..90a9e65c --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/response/ErrorDetail.java @@ -0,0 +1,21 @@ +package com.example.backend.global.response; + +/** + * ErrorDetail + *

@NotNull등과 같은 필드 에러 발생시 처리할 클래스

+ * @param field + * @param reason + * @author Kim Dong O + */ +public record ErrorDetail(String field, String reason) { + + /** + * ErrorDetail 생성 팩토리 메서드 + * @param field 예외가 발생한 필드 이름 + * @param reason 예외가 발생한 이유 + * @return {@link ErrorDetail} + */ + public static ErrorDetail of(String field, String reason) { + return new ErrorDetail(field, reason); + } +} diff --git a/backend/src/main/java/com/example/backend/global/response/GenericResponse.java b/backend/src/main/java/com/example/backend/global/response/GenericResponse.java new file mode 100644 index 00000000..848b2c55 --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/response/GenericResponse.java @@ -0,0 +1,77 @@ +package com.example.backend.global.response; + +import java.time.ZonedDateTime; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; + +/** + * GenericResponse + *

요청이 성공했을 때 공통 응답 클래스 입니다.

+ * + * @author Kim Dong O + */ +@Getter +public class GenericResponse { + private final ZonedDateTime timestamp; + private boolean isSuccess; + private String message; + private final T data; + + @Builder(access = AccessLevel.PRIVATE) + private GenericResponse(T data, String message, boolean isSuccess) { + this.timestamp = ZonedDateTime.now(); + this.data = data; + this.message = message; + this.isSuccess = isSuccess; + } + + /** + * 요청이 성공하고 응답할 데이터, 메세지가 있을 때 + * @param data + * @param message + * @return {@link GenericResponse} GenericResponse + */ + public static GenericResponse of(T data, String message) { + return GenericResponse.builder() + .data(data) + .message(message) + .isSuccess(true) + .build(); + } + + /** + * 요청이 성공하고 응답할 메세지만 있을 때 + * @param message + * @return {@link GenericResponse} GenericResponse + */ + public static GenericResponse of(String message) { + return GenericResponse.builder() + .message(message) + .isSuccess(true) + .build(); + } + + /** + * 요청이 성공하고 응답할 데이터만 있을 때 + * @param data + * @return {@link GenericResponse} GenericResponse + */ + public static GenericResponse of(T data) { + return GenericResponse.builder() + .data(data) + .isSuccess(true) + .build(); + } + + /** + * 요청이 성공하고 응답 메세지, 데이터가 없을 때 + * @return {@link GenericResponse} GenericResponse + */ + public static GenericResponse of() { + return GenericResponse.builder() + .isSuccess(true) + .build(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/example/backend/global/response/HttpErrorInfo.java b/backend/src/main/java/com/example/backend/global/response/HttpErrorInfo.java new file mode 100644 index 00000000..8a50bda0 --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/response/HttpErrorInfo.java @@ -0,0 +1,69 @@ +package com.example.backend.global.response; + +import java.time.ZonedDateTime; +import java.util.List; + +import lombok.AccessLevel; +import lombok.Builder; + +/** + * HttpErrorInfo + *

예외 발생시 사용할 공통 응답 클래스

+ * @param code + * @param path + * @param message + * @param errorDetails + * @param timeStamp + * @author Kim Dong O + */ +public record HttpErrorInfo(String code, String path, String message, ZonedDateTime timeStamp, List errorDetails) { + + @Builder(access = AccessLevel.PROTECTED) + public HttpErrorInfo(String code, String path, String message, ZonedDateTime timeStamp, + List errorDetails) { + this.code = code; + this.path = path; + this.message = message; + this.timeStamp = timeStamp; + this.errorDetails = errorDetails; + } + + /** + * HttpErrorInfo 생성 팩토리 메서드 + * + * @param code 커스텀 예외 코드 + * @param path 예외가 발생한 요청 경로 + * @param message 예외가 발생한 이유 + * @param errorDetails 필드 에러 정보 + * @author Kim Dong O + * @return {@link HttpErrorInfo} + */ + public static HttpErrorInfo of(String code, String path, String message, List errorDetails) { + return HttpErrorInfo.builder() + .code(code) + .path(path) + .message(message) + .errorDetails(errorDetails) + .timeStamp(ZonedDateTime.now()) + .build(); + } + + /** + * HttpErrorInfo 생성 팩토리 메서드 + *

code, path, message만 있을 때

+ * @param code 커스텀 예외 코드 + * @param path 예외가 발생한 요청 경로 + * @param message 예외가 발생한 이유 + * @author Kim Dong O + * @return {@link HttpErrorInfo} + */ + public static HttpErrorInfo of(String code, String path, String message) { + return HttpErrorInfo.builder() + .code(code) + .path(path) + .message(message) + .timeStamp(ZonedDateTime.now()) + .build(); + } + +} diff --git a/backend/src/main/java/com/example/backend/global/scheduled/SchedulerService.java b/backend/src/main/java/com/example/backend/global/scheduled/SchedulerService.java new file mode 100644 index 00000000..61333482 --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/scheduled/SchedulerService.java @@ -0,0 +1,41 @@ +package com.example.backend.global.scheduled; + +import java.time.LocalTime; +import java.time.ZonedDateTime; +import java.util.List; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.example.backend.domain.orders.entity.Orders; +import com.example.backend.domain.orders.repository.OrdersRepository; +import com.example.backend.global.mail.service.MailService; +import com.example.backend.global.mail.util.TemplateName; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Component +@RequiredArgsConstructor +@Slf4j +public class SchedulerService { + private final OrdersRepository ordersRepository; + private final MailService mailService; + + @Transactional + @Scheduled(cron = "0 0 14 * * ?") + public void scheduleOrderProcessing() { + ZonedDateTime now = ZonedDateTime.now(); + ZonedDateTime startTime = now.minusDays(1).with(LocalTime.of(14, 0)); + ZonedDateTime endTime = now.with(LocalTime.of(14, 0)); + + List ordersUsernameList = ordersRepository.findUsernameByReady(startTime, endTime); + + if (!ordersUsernameList.isEmpty()) { + ordersRepository.bulkUpdateDeliveryStatus(startTime, endTime); + mailService.sendDeliveryStartEmail(ordersUsernameList, TemplateName.DELIVERY_START); + } + } + +} diff --git a/backend/src/main/java/com/example/backend/global/validation/ValidationGroups.java b/backend/src/main/java/com/example/backend/global/validation/ValidationGroups.java new file mode 100644 index 00000000..7a00e86d --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/validation/ValidationGroups.java @@ -0,0 +1,33 @@ +package com.example.backend.global.validation; + +/** + * ValidationGroups + *

검증 그룹을 정의해놓은 클래스

+ * @author Kim Dong O + */ +public class ValidationGroups { + public interface NotNullGroup { + } + + public interface NotEmptyGroup { + } + + public interface NotBlankGroup { + } + + public interface PatternGroup { + } + + public interface SizeGroup { + } + + public interface ValidEnumGroup { + } + + public interface MinGroup { + } + + public interface MaxGroup { + } + +} diff --git a/backend/src/main/java/com/example/backend/global/validation/ValidationSequence.java b/backend/src/main/java/com/example/backend/global/validation/ValidationSequence.java new file mode 100644 index 00000000..8d1e17ec --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/validation/ValidationSequence.java @@ -0,0 +1,21 @@ +package com.example.backend.global.validation; + +import static com.example.backend.global.validation.ValidationGroups.*; + +import jakarta.validation.GroupSequence; + +/** + * ValidationSequence + * 검증 어노테이션의 순서를 지정하는 인터페이스 + *

NotNullGroup(@NotNull) → NotBlankGroup(@NotBlank) → ValidEnumGroup(@ValidEnum) →
+ * → PatternGroup(@Pattern) → SizeGroup(@Size) + * → MinGroup(@Min) → MaxGroup(@Max)

+ * + * @author : Kim Dong O + */ + +@GroupSequence({NotNullGroup.class, NotEmptyGroup.class, NotBlankGroup.class, SizeGroup.class, MinGroup.class, MaxGroup.class, + ValidEnumGroup.class, + PatternGroup.class}) +public interface ValidationSequence { +} diff --git a/backend/src/main/java/com/example/backend/global/validation/annotation/PasswordMatch.java b/backend/src/main/java/com/example/backend/global/validation/annotation/PasswordMatch.java new file mode 100644 index 00000000..8c8cd161 --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/validation/annotation/PasswordMatch.java @@ -0,0 +1,28 @@ +package com.example.backend.global.validation.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import com.example.backend.global.validation.validator.PasswordMatchValidator; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +/** + * PasswordMatch + *

검증할 Object 에 사용할 어노테이션
+ * 반드시 아래 구현체를 상속받아 객체를 구현한 후에 사용해야 합니다.
+ * 구현체 : {@link com.example.backend.global.validation.validator.PasswordMatchable}
+ * Validation: {@link PasswordMatchValidator}

+ * @author Kim Dong O + */ +@Constraint(validatedBy = PasswordMatchValidator.class) +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface PasswordMatch { + String message() default "비밀번호와 비밀번호 확인이 일치하지 않습니다."; + Class[] groups() default {}; + Class[] payload() default {}; +} diff --git a/backend/src/main/java/com/example/backend/global/validation/annotation/ValidEnum.java b/backend/src/main/java/com/example/backend/global/validation/annotation/ValidEnum.java new file mode 100644 index 00000000..f138f62b --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/validation/annotation/ValidEnum.java @@ -0,0 +1,34 @@ +package com.example.backend.global.validation.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import com.example.backend.global.validation.validator.EnumValidator; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +/** + * ValidEnum + *

검증할 Enum 필드에 사용할 어노테이션
+ * null 옵션을 true로 설정시 일치하는 Enum Value가 없으면 null로 초기화
+ * enumClass 옵션은 필수로 설정할 것
+ * Validation: {@link EnumValidator}

+ * @author Kim Dong O + */ +@Constraint(validatedBy = EnumValidator.class) +@Target({ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ValidEnum { + String message() default "요청 값이 유효하지 않습니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; + + Class> enumClass(); + + boolean nullable() default false; +} diff --git a/backend/src/main/java/com/example/backend/global/validation/annotation/ValidNickname.java b/backend/src/main/java/com/example/backend/global/validation/annotation/ValidNickname.java new file mode 100644 index 00000000..ee7a4685 --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/validation/annotation/ValidNickname.java @@ -0,0 +1,28 @@ +package com.example.backend.global.validation.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import com.example.backend.global.validation.validator.ValidNicknameValidator; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +/** + * ValidNickname + *

검증할 Nickname 필드에 사용할 어노테이션

+ *

Validation: {@link ValidNicknameValidator}

+ * @author Kim Dong O + */ +@Constraint(validatedBy = ValidNicknameValidator.class) +@Target({ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ValidNickname { + String message() default "유효하지 않은 회원 이름 입니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/backend/src/main/java/com/example/backend/global/validation/annotation/ValidPassword.java b/backend/src/main/java/com/example/backend/global/validation/annotation/ValidPassword.java new file mode 100644 index 00000000..c08af1b5 --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/validation/annotation/ValidPassword.java @@ -0,0 +1,28 @@ +package com.example.backend.global.validation.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import com.example.backend.global.validation.validator.ValidPasswordValidator; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +/** + * ValidPassword + *

검증할 Password 필드에 사용할 어노테이션

+ *

Validation: {@link ValidPasswordValidator}

+ * @author Kim Dong O + */ +@Constraint(validatedBy = ValidPasswordValidator.class) +@Target({ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ValidPassword { + String message() default "공백 없이 비밀번호는 최소 8자리, 최대 20자리이며 대소문자, 숫자, 특수문자 1개씩 필수 입력해야 합니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/backend/src/main/java/com/example/backend/global/validation/annotation/ValidUsername.java b/backend/src/main/java/com/example/backend/global/validation/annotation/ValidUsername.java new file mode 100644 index 00000000..ef15b8fd --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/validation/annotation/ValidUsername.java @@ -0,0 +1,28 @@ +package com.example.backend.global.validation.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import com.example.backend.global.validation.validator.ValidUsernameValidator; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +/** + * ValidUsername + *

검증할 Username 필드에 사용할 어노테이션

+ *

Validation: {@link ValidUsernameValidator}

+ * @author Kim Dong O + */ +@Constraint(validatedBy = ValidUsernameValidator.class) +@Target({ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ValidUsername { + String message() default "유효하지 않은 이메일 입니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/backend/src/main/java/com/example/backend/global/validation/validator/EnumValidator.java b/backend/src/main/java/com/example/backend/global/validation/validator/EnumValidator.java new file mode 100644 index 00000000..7b0b36df --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/validation/validator/EnumValidator.java @@ -0,0 +1,40 @@ +package com.example.backend.global.validation.validator; + +import java.util.Arrays; + +import com.example.backend.global.validation.annotation.ValidEnum; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +/** + * EnumValidator + *

Enum 값이 유효한지 확인하는 Validator

+ * @author Kim Dong O + */ +public class EnumValidator implements ConstraintValidator { + private ValidEnum validEnum; + + @Override + public void initialize(ValidEnum constraintAnnotation) { + this.validEnum = constraintAnnotation; + } + + @Override + public boolean isValid(Enum value, ConstraintValidatorContext context) { + boolean result = false; + // nullable이 true, value가 null일 때 + if (this.validEnum.nullable() && value == null) { + return result = true; + } + + Object[] enumValues = this.validEnum.enumClass().getEnumConstants(); + + if (enumValues != null) { + result = Arrays.stream(enumValues) + .anyMatch(enumValue -> value == enumValue); + } + + return result; + } +} diff --git a/backend/src/main/java/com/example/backend/global/validation/validator/PasswordMatchValidator.java b/backend/src/main/java/com/example/backend/global/validation/validator/PasswordMatchValidator.java new file mode 100644 index 00000000..2359d6d2 --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/validation/validator/PasswordMatchValidator.java @@ -0,0 +1,26 @@ +package com.example.backend.global.validation.validator; + + + +import com.example.backend.global.validation.annotation.PasswordMatch; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +/** + * PasswordMatchValidator + *

password, passwordCheck 두개가 동일한지 검증하는 Validation

+ * @author Kim Dong O + */ +public class PasswordMatchValidator implements ConstraintValidator { + + @Override + public boolean isValid(PasswordMatchable passwordMatchable, ConstraintValidatorContext constraintValidatorContext) { + return (passwordMatchable.getPasswordCheck() != null) && (passwordMatchable.getPassword() + .equals(passwordMatchable.getPasswordCheck())); + } + + @Override + public void initialize(PasswordMatch constraintAnnotation) { + } +} diff --git a/backend/src/main/java/com/example/backend/global/validation/validator/PasswordMatchable.java b/backend/src/main/java/com/example/backend/global/validation/validator/PasswordMatchable.java new file mode 100644 index 00000000..349e2858 --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/validation/validator/PasswordMatchable.java @@ -0,0 +1,12 @@ +package com.example.backend.global.validation.validator; + +/** + * PasswordMatchable + *

PasswordMatchValidator 사용을 위해 정의한 인터페이스

+ * @author Kim Dong O + */ +public interface PasswordMatchable { + String getPassword(); + + String getPasswordCheck(); +} diff --git a/backend/src/main/java/com/example/backend/global/validation/validator/ValidNicknameValidator.java b/backend/src/main/java/com/example/backend/global/validation/validator/ValidNicknameValidator.java new file mode 100644 index 00000000..3d4f4410 --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/validation/validator/ValidNicknameValidator.java @@ -0,0 +1,26 @@ +package com.example.backend.global.validation.validator; + +import java.util.regex.Pattern; + +import com.example.backend.global.validation.annotation.ValidNickname; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +/** + * ValidNicknameValidator + *

닉네임 패턴 검증 Validator

+ * @author Kim Dong O + */ +public class ValidNicknameValidator implements ConstraintValidator { + private static final String NICKNAME_REGEX = "^[가-힣a-zA-Z0-9]{2,}$"; + + @Override + public void initialize(ValidNickname constraintAnnotation) { + } + + @Override + public boolean isValid(String nickname, ConstraintValidatorContext constraintValidatorContext) { + return Pattern.matches(NICKNAME_REGEX, nickname); + } +} diff --git a/backend/src/main/java/com/example/backend/global/validation/validator/ValidPasswordValidator.java b/backend/src/main/java/com/example/backend/global/validation/validator/ValidPasswordValidator.java new file mode 100644 index 00000000..4337c94e --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/validation/validator/ValidPasswordValidator.java @@ -0,0 +1,26 @@ +package com.example.backend.global.validation.validator; + +import java.util.regex.Pattern; + +import com.example.backend.global.validation.annotation.ValidPassword; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +/** + * ValidPasswordValidator + *

비밀번호 패턴 검증 Validator

+ * @author Kim Dong O + */ +public class ValidPasswordValidator implements ConstraintValidator { + private static final String PASSWORD_REGEX = "^(?=.*[a-zA-Z])(?=.*[~!@#$%^&*+=()_-])(?=.*[0-9])[^\s]{8,20}$"; + + @Override + public void initialize(ValidPassword constraintAnnotation) { + } + + @Override + public boolean isValid(String password, ConstraintValidatorContext constraintValidatorContext) { + return (password != null) && Pattern.matches(PASSWORD_REGEX, password); + } +} diff --git a/backend/src/main/java/com/example/backend/global/validation/validator/ValidUsernameValidator.java b/backend/src/main/java/com/example/backend/global/validation/validator/ValidUsernameValidator.java new file mode 100644 index 00000000..c9ca866e --- /dev/null +++ b/backend/src/main/java/com/example/backend/global/validation/validator/ValidUsernameValidator.java @@ -0,0 +1,26 @@ +package com.example.backend.global.validation.validator; + +import java.util.regex.Pattern; + +import org.springframework.util.StringUtils; + +import com.example.backend.global.validation.annotation.ValidUsername; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class ValidUsernameValidator implements ConstraintValidator { + private static final String USERNAME_REGEX = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,6}$"; + + @Override + public void initialize(ValidUsername constraintAnnotation) { + } + + @Override + public boolean isValid(String username, ConstraintValidatorContext constraintValidatorContext) { + + return StringUtils.hasText(username) && Pattern.matches(USERNAME_REGEX, username); + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml deleted file mode 100644 index e0ef93ed..00000000 --- a/backend/src/main/resources/application.yml +++ /dev/null @@ -1,19 +0,0 @@ -spring: - application: - name: backend - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${DATABASE_URL} - username: ${DATABASE_USERNAME} - password: ${DATABASE_PASSWORD} - jpa: - hibernate: - ddl-auto: update - properties: - hibernate: - show_sql: true - format_sql: true - dialect: org.hibernate.dialect.MySQLDialect -logging: - level: - org.springframework.security: DEBUG \ No newline at end of file diff --git a/backend/src/main/resources/mail-templates/delivery-start-template.html b/backend/src/main/resources/mail-templates/delivery-start-template.html new file mode 100644 index 00000000..86bfd656 --- /dev/null +++ b/backend/src/main/resources/mail-templates/delivery-start-template.html @@ -0,0 +1,206 @@ + + + + + + Email Confirmation + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+

+ 배송 시작 알림 메일

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+

본 이메일은 배송 시작 안내 메일 입니다.

+

+ +
+ + + + +
+ + + + +
+

주문하신 상품의 배송이 시작되었습니다.

+
+
+
+

* 본 메일은 발신전용 메일이므로 문의 및 회신하실 경우 답변되지 않습니다.

+
+ +
+ + + + diff --git a/backend/src/main/resources/mail-templates/email-verify-template.html b/backend/src/main/resources/mail-templates/email-verify-template.html new file mode 100644 index 00000000..21e7da12 --- /dev/null +++ b/backend/src/main/resources/mail-templates/email-verify-template.html @@ -0,0 +1,208 @@ + + + + + + Email Confirmation + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+

+ 이메일 인증 메일

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+

본 이메일은 인증번호 안내 메일 입니다.

+

아래 [인증번호] 를 입력해주세요! +

+ +
+ + + + +
+ + + + +
+ [인증번호] +

+

+
+
+

* 본 메일은 발신전용 메일이므로 문의 및 회신하실 경우 답변되지 않습니다.

+
+ +
+ + + + diff --git a/backend/src/main/resources/mail-templates/password-reset-template.html b/backend/src/main/resources/mail-templates/password-reset-template.html new file mode 100644 index 00000000..a062d800 --- /dev/null +++ b/backend/src/main/resources/mail-templates/password-reset-template.html @@ -0,0 +1,208 @@ + + + + + + Email Confirmation + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+

+ 비밀번호 초기화 메일

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+

본 이메일은 임시 비밀번호 안내 메일 입니다.

+

아래 [임시 비밀번호] 로 로그인해주세요!! +

+ +
+ + + + +
+ + + + +
+ [임시 비밀번호] +

+

+
+
+

* 본 메일은 발신전용 메일이므로 문의 및 회신하실 경우 답변되지 않습니다.

+
+ +
+ + + + diff --git a/backend/src/main/resources/mail-templates/signup-verify-template.html b/backend/src/main/resources/mail-templates/signup-verify-template.html new file mode 100644 index 00000000..0e413215 --- /dev/null +++ b/backend/src/main/resources/mail-templates/signup-verify-template.html @@ -0,0 +1,210 @@ + + + + + + Email Confirmation + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+

+ 이메일 인증 메일

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+

본 이메일은 이메일 인증 메일 입니다.

+

아래 [이메일 인증하기] 를 눌러주세요! +

+ +
+ + + + +
+ + + + +
+ + 이메일 인증하기 + +
+
+
+

* 본 메일은 발신전용 메일이므로 문의 및 회신하실 경우 답변되지 않습니다.

+
+ +
+ + + + diff --git a/backend/src/test/java/com/example/backend/BackendApplicationTests.java b/backend/src/test/java/com/example/backend/BackendApplicationTests.java index e6511ba5..8aa1429b 100644 --- a/backend/src/test/java/com/example/backend/BackendApplicationTests.java +++ b/backend/src/test/java/com/example/backend/BackendApplicationTests.java @@ -2,7 +2,12 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import com.example.backend.global.config.CorsConfig; +import com.example.backend.global.config.TestSecurityConfig; + +@Import({CorsConfig.class, TestSecurityConfig.class}) @SpringBootTest class BackendApplicationTests { diff --git a/backend/src/test/java/com/example/backend/domain/cart/controller/CartControllerTest.java b/backend/src/test/java/com/example/backend/domain/cart/controller/CartControllerTest.java new file mode 100644 index 00000000..6a1517c5 --- /dev/null +++ b/backend/src/test/java/com/example/backend/domain/cart/controller/CartControllerTest.java @@ -0,0 +1,202 @@ +package com.example.backend.domain.cart.controller; + +import com.example.backend.domain.cart.dto.CartDeleteForm; +import com.example.backend.domain.cart.dto.CartForm; +import com.example.backend.domain.cart.dto.CartResponse; +import com.example.backend.domain.cart.dto.CartUpdateForm; +import com.example.backend.domain.cart.exception.CartErrorCode; +import com.example.backend.domain.cart.exception.CartException; +import com.example.backend.domain.cart.service.CartService; +import com.example.backend.domain.member.entity.Member; +import com.example.backend.global.auth.model.CustomUserDetails; +import com.example.backend.global.response.GenericResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.test.context.support.WithMockUser; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class CartControllerTest { + + @Mock + private CartService cartService; + + @Mock + private CustomUserDetails customUserDetails; + + @InjectMocks + private CartController cartController; + + private Member member; + private CartForm cartForm; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + + // Member 객체 모킹 + member = mock(Member.class); + when(member.getId()).thenReturn(1L); + when(customUserDetails.getMember()).thenReturn(member); + + cartForm = new CartForm(1L, 2); + } + + /** + * addCartItem() 메서드 테스트 + * - 장바구니에 상품 추가 + * @throws CartException + */ + @Test + @WithMockUser + @DisplayName("장바구니에 상품 추가") + void addCartItem() throws CartException { + Long cartId = 1L; + + when(cartService.addCartItem(cartForm, member)).thenReturn(cartId); + + ResponseEntity> response = cartController.addCartItem(cartForm, customUserDetails); + + // then + assertEquals(HttpStatus.CREATED, response.getStatusCode()); + assertNotNull(response.getBody()); + assertEquals(cartId, response.getBody().getData()); + + verify(cartService).addCartItem(cartForm, member); + } + + /** + * getCarts() 메서드 테스트 + * - 회원의 장바구니 목록 조회 + * - 빈 장바구니 목록 조회 + * @throws CartException + */ + @Test + @WithMockUser + @DisplayName("회원의 장바구니 목록 조회 성공") + void getCarts_Success() { + // given + Long memberId = 1L; + CartResponse cartResponse = new CartResponse(1L,1L,"testProductName", 10, 1000, 10000, "test.jpg"); + List cartResponses = List.of(cartResponse); + + when(cartService.getCartByMember(member)).thenReturn(cartResponses); + + // when + ResponseEntity>> response = + cartController.getCart(customUserDetails); + + // then + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + assertEquals(1, response.getBody().getData().size()); + assertEquals(cartResponse.productName(), response.getBody().getData().get(0).productName()); + + verify(cartService).getCartByMember(member); + } + + @Test + @WithMockUser + @DisplayName("빈 장바구니 목록 조회") + void getCarts_WithEmptyCart_ReturnsEmptyList() { + // given + Long memberId = 1L; + List emptyCartResponses = List.of(); + + when(cartService.getCartByMember(member)).thenReturn(emptyCartResponses); + + // when + ResponseEntity>> response = + cartController.getCart(customUserDetails); + + // then + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + assertTrue(response.getBody().getData().isEmpty()); + + verify(cartService).getCartByMember(member); + } + + /** + * updateCartItemQuantity() 메서드 테스트 + * - 장바구니 상품 수량 변경 + * @throws CartException + */ + @Test + @WithMockUser + @DisplayName("장바구니 상품 수량 변경") + void updateCartItemQuantity() throws CartException { + // given + Long cartId = 1L; + CartUpdateForm cartUpdateForm = new CartUpdateForm(1L, 3); + + when(cartService.updateCartItemQuantity(cartUpdateForm, member)).thenReturn(cartId); + + // when + ResponseEntity> response = cartController + .updateCartItemQuantity(cartUpdateForm, customUserDetails); + + // then + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + assertEquals(cartId, response.getBody().getData()); + + verify(cartService).updateCartItemQuantity(cartUpdateForm, member); + } + + /** + * deleteCartItem() 메서드 테스트 + * - 장바구니 상품 삭제 성공 + * - 존재하지 않는 상품 삭제 시도 + */ + @Test + @WithMockUser + @DisplayName("장바구니 상품 삭제 성공") + void deleteCartItem_Success() { + // given + Long productId = 1L; + CartDeleteForm cartDeleteForm = new CartDeleteForm(productId); + + when(cartService.deleteCartItem(cartDeleteForm, member)).thenReturn(productId); + + // when + ResponseEntity> response = + cartController.deleteCartItem(cartDeleteForm, customUserDetails); + + // then + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + assertEquals(productId, response.getBody().getData()); + + verify(cartService).deleteCartItem(cartDeleteForm, member); + } + + @Test + @WithMockUser + @DisplayName("존재하지 않는 장바구니 상품 삭제 시도") + void deleteCartItem_WithNonExistingProduct_ThrowsException() { + // given + Long nonExistingProductId = 999L; + CartDeleteForm cartDeleteForm = new CartDeleteForm(nonExistingProductId); + + when(cartService.deleteCartItem(cartDeleteForm, member)) + .thenThrow(new CartException(CartErrorCode.PRODUCT_NOT_FOUND_IN_CART)); + + // when & then + CartException exception = assertThrows(CartException.class, () -> + cartController.deleteCartItem(cartDeleteForm, customUserDetails)); + + assertEquals(CartErrorCode.PRODUCT_NOT_FOUND_IN_CART.getCode(), exception.getCode()); + + verify(cartService).deleteCartItem(cartDeleteForm, member); + } +} diff --git a/backend/src/test/java/com/example/backend/domain/cart/repository/CartRepositoryTest.java b/backend/src/test/java/com/example/backend/domain/cart/repository/CartRepositoryTest.java new file mode 100644 index 00000000..c8b9be0e --- /dev/null +++ b/backend/src/test/java/com/example/backend/domain/cart/repository/CartRepositoryTest.java @@ -0,0 +1,184 @@ +package com.example.backend.domain.cart.repository; + +import com.example.backend.domain.cart.entity.Cart; +import com.example.backend.domain.common.Address; +import com.example.backend.domain.member.entity.Member; +import com.example.backend.domain.member.entity.MemberStatus; +import com.example.backend.domain.member.entity.Role; +import com.example.backend.domain.member.repository.MemberRepository; +import com.example.backend.domain.product.entity.Product; +import com.example.backend.domain.product.repository.ProductRepository; +import org.hibernate.Hibernate; +import org.junit.jupiter.api.BeforeEach; +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.orm.jpa.DataJpaTest; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@DataJpaTest +class CartRepositoryTest { + + @Autowired + private CartRepository cartRepository; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private MemberRepository memberRepository; + + private Member member; + private Product product; + + @BeforeEach + void setup() { + // Given: 회원 및 상품 객체를 생성 후 DB에 저장 + Address address = Address.builder() + .city("testCity") + .district("testDistrict") + .country("testCountry") + .detail("testDetail") + .build(); + + member = Member.builder() + .username("testUser") + .nickname("testNickname") + .password("!testPassword1234") + .role(Role.ROLE_USER) + .memberStatus(MemberStatus.ACTIVE) + .address(address) + .build(); + memberRepository.save(member); + + product = Product.builder() + .name("Test Product") + .content("Test Product Content") + .price(1000) + .quantity(1) + .imgUrl("http://test.com/image.jpg") + .build(); + productRepository.save(product); + } + + @Test + @DisplayName("장바구니에 해당 상품과 회원이 존재하는지 확인") + void existsByProductId_IdAndMemberId_Id() { + // Given + Cart cart = Cart.builder() + .member(member) + .product(product) + .quantity(1) + .build(); + + cartRepository.save(cart); + + // When + boolean exists = cartRepository.existsByProductIdAndMemberId( + product.getId(), + member.getId() + ); + + // Then + assertTrue(exists); + } + + @Test + @DisplayName("회원의 장바구니 목록을 상품 정보와 함께 조회") + void findAllByMemberWithProducts() { + // Given + Cart cart1 = Cart.builder() + .member(member) + .product(product) + .quantity(1) + .build(); + + Product product2 = Product.builder() + .name("Test Product 2") + .content("Test Product Content 2") + .price(2000) + .quantity(2) + .imgUrl("http://test.com/image2.jpg") + .build(); + productRepository.save(product2); + + Cart cart2 = Cart.builder() + .member(member) + .product(product2) + .quantity(2) + .build(); + + cartRepository.saveAll(List.of(cart1, cart2)); + + // When + List cartList = cartRepository.findAllByMemberWithProducts(member); + + // Then + assertThat(cartList).hasSize(2); + assertThat(cartList).allSatisfy(cart -> { + assertThat(cart.getMember()).isEqualTo(member); + assertThat(cart.getProduct()).isNotNull(); + // Hibernate.isInitialized()로 페치 조인이 정상적으로 동작했는지 검증 + assertThat(Hibernate.isInitialized(cart.getProduct())).isTrue(); + }); + + // 조회된 상품들의 정보 검증 + assertThat(cartList).extracting("product.name") + .containsExactlyInAnyOrder("Test Product", "Test Product 2"); + assertThat(cartList).extracting("quantity") + .containsExactlyInAnyOrder(1, 2); + } + + @Test + @DisplayName("장바구니 상품 조회 및 수량 변경 테스트") + void findByProductAndMemberWithQuantityChange() { + // Given + Cart cart = Cart.builder() + .member(member) + .product(product) + .quantity(1) + .build(); + cartRepository.save(cart); + + // When & Then: 최초 조회 검증 + Optional foundCart = cartRepository.findByProductIdAndMemberId( + product.getId(), + member.getId() + ); + assertInitialCart(foundCart); + + // When & Then: 수량 변경 후 재조회 검증 + updateAndVerifyCartQuantity(foundCart.get()); + } + + @DisplayName("초기 장바구니 상태 검증") + private void assertInitialCart(Optional foundCart) { + assertTrue(foundCart.isPresent(), "장바구니 항목이 존재해야 합니다"); + Cart cart = foundCart.get(); + assertThat(cart.getProduct()).isEqualTo(product); + assertThat(cart.getMember()).isEqualTo(member); + assertThat(cart.getQuantity()).isEqualTo(1); + } + + @DisplayName("장바구니 수량 변경 및 검증") + private void updateAndVerifyCartQuantity(Cart cart) { + // When: 수량 변경 + cart.updateQuantity(3); + cartRepository.save(cart); + + // Then: 변경된 수량 검증 + Optional updatedCart = cartRepository.findByProductIdAndMemberId( + product.getId(), + member.getId() + ); + assertTrue(updatedCart.isPresent(), "수정된 장바구니 항목이 존재해야 합니다"); + assertThat(updatedCart.get().getQuantity()) + .as("장바구니 수량이 3으로 변경되어야 합니다") + .isEqualTo(3); + } +} diff --git a/backend/src/test/java/com/example/backend/domain/cart/service/CartServiceTest.java b/backend/src/test/java/com/example/backend/domain/cart/service/CartServiceTest.java new file mode 100644 index 00000000..9c6696a6 --- /dev/null +++ b/backend/src/test/java/com/example/backend/domain/cart/service/CartServiceTest.java @@ -0,0 +1,233 @@ +package com.example.backend.domain.cart.service; + +import com.example.backend.domain.cart.dto.CartDeleteForm; +import com.example.backend.domain.cart.dto.CartForm; +import com.example.backend.domain.cart.dto.CartResponse; +import com.example.backend.domain.cart.dto.CartUpdateForm; +import com.example.backend.domain.cart.entity.Cart; +import com.example.backend.domain.cart.exception.CartErrorCode; +import com.example.backend.domain.cart.exception.CartException; +import com.example.backend.domain.cart.repository.CartRepository; +import com.example.backend.domain.member.entity.Member; +import com.example.backend.domain.member.entity.MemberStatus; +import com.example.backend.domain.member.entity.Role; +import com.example.backend.domain.product.entity.Product; +import com.example.backend.domain.product.service.ProductService; +import com.example.backend.global.auth.exception.AuthException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +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.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class CartServiceTest { + + @InjectMocks + private CartService cartService; + + @Mock + private CartRepository cartRepository; + + @Mock + private ProductService productService; + + private Member member; + private Product product; + private CartForm cartForm; + private Cart cart; + + @BeforeEach + void setUp() { + member = Member.builder() + .id(1L) + .username("testUser") + .password("!testPassword1234") + .role(Role.ROLE_USER) + .memberStatus(MemberStatus.ACTIVE) + .build(); + + product = Product.builder() + .name("Test Product") + .content("Test Content") + .price(1000) + .imgUrl("test.jpg") + .quantity(10) + .build(); + + cartForm = new CartForm( 1L, 5); + + cart = Cart.builder() + .id(1L) + .member(member) + .product(product) + .quantity(5) + .build(); + } + + /** + * addCartItem() 메서드 테스트 + * - 장바구니에 상품 추가 성공 + * - 회원 정보가 일치하지 않으면 예외 발생 + * - 수량이 0 이하면 예외 발생 + * - 이미 장바구니에 존재하는 상품이면 예외 발생 + */ + @Test + @DisplayName("장바구니에 상품 추가 성공") + void addCartItem_Success() { + // given + given(productService.findById(cartForm.productId())).willReturn(product); + given(cartRepository.existsByProductIdAndMemberId(cartForm.productId(), member.getId())) + .willReturn(false); + given(cartRepository.save(any(Cart.class))).willReturn(cart); + + // when + Long savedCartId = cartService.addCartItem(cartForm, member); + + // then + assertThat(savedCartId).isEqualTo(cart.getId()); + verify(cartRepository).save(any(Cart.class)); + } + + @Test + @DisplayName("이미 장바구니에 존재하는 상품이면 예외 발생") + void addCartItem_WithExistingProduct_ThrowsCartException() { + // given + given(cartRepository.existsByProductIdAndMemberId(cartForm.productId(), member.getId())) + .willReturn(true); + + // when & then + assertThatThrownBy(() -> cartService.addCartItem(cartForm, member)) + .isInstanceOf(CartException.class) + .hasMessage("이미 장바구니에 추가된 상품입니다."); + } + + /** + * getCartsByMember() 메서드 테스트 + * - 회원의 장바구니 목록 조회 성공 + */ + + @Test + @DisplayName("회원의 장바구니 목록 조회 성공") + void getCartsByMember_Success() { + // given + List cartList = List.of(cart); + given(cartRepository.findAllByMemberWithProducts(member)).willReturn(cartList); + + // when + List result = cartService.getCartByMember(member); + + // then + assertThat(result).isNotEmpty(); + assertThat(result.size()).isEqualTo(1); + verify(cartRepository).findAllByMemberWithProducts(member); + } + + /** + * updateCartItemQuantity() 메서드 테스트 + * - 장바구니 수량 업데이트 성공 + * - 수량이 0 이하일 때 예외 발생 + * - 장바구니에 해당 상품이 없는 경우 예외 발생 + * - 수량이 변경되지 않은 경우 예외 발생 + */ + + @Test + @DisplayName("장바구니 수량 업데이트 성공") + void updateCartItemQuantity_Success() { + // Given + CartUpdateForm cartUpdateForm = new CartUpdateForm(product.getId(), 10); + given(cartRepository.findByProductIdAndMemberId(cartUpdateForm.productId(), member.getId())) + .willReturn(Optional.of(cart)); + + // When + Long updatedCartId = cartService.updateCartItemQuantity(cartUpdateForm, member); + + // Then + assertThat(updatedCartId).isEqualTo(cart.getId()); + assertThat(cart.getQuantity()).isEqualTo(cartUpdateForm.quantity()); + } + + @Test + @DisplayName("장바구니에 해당 상품이 없는 경우 예외 발생") + void updateCartItemQuantity_WithProductNotInCart_ThrowsCartException() { + // given + CartUpdateForm cartUpdateForm = new CartUpdateForm(1L, 3); + given(cartRepository.findByProductIdAndMemberId(cartUpdateForm.productId(), member.getId())) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> cartService.updateCartItemQuantity(cartUpdateForm, member)) + .isInstanceOf(CartException.class) + .satisfies(exception -> { + CartException cartException = (CartException) exception; + assertThat(cartException.getCode()).isEqualTo(CartErrorCode.PRODUCT_NOT_FOUND_IN_CART.getCode()); + }); + } + + @Test + @DisplayName("수량이 변경되지 않은 경우 예외 발생") + void updateCartItemQuantity_WithSameQuantity_ThrowsCartException() { + // given + cart.updateQuantity(5); // 현재 수량을 5로 설정 + CartUpdateForm cartUpdateForm = new CartUpdateForm(1L, 5); // 같은 수량으로 업데이트 시도 + given(cartRepository.findByProductIdAndMemberId(cartUpdateForm.productId(), member.getId())) + .willReturn(Optional.of(cart)); + + // when & then + assertThatThrownBy(() -> cartService.updateCartItemQuantity(cartUpdateForm, member)) + .isInstanceOf(CartException.class) + .satisfies(exception -> { + CartException cartException = (CartException) exception; + assertThat(cartException.getCode()).isEqualTo(CartErrorCode.SAME_QUANTITY_IN_CART.getCode()); + }); + } + + /** + * deleteCartItem() 메서드 테스트 + * - 장바구니 상품 삭제 성공 + * - 장바구니에 해당 상품이 없는 경우 예외 발생 + */ + @Test + @DisplayName("장바구니 상품 삭제 성공") + void deleteCartItem_Success() { + // given + CartDeleteForm cartDeleteForm = new CartDeleteForm(product.getId()); + given(cartRepository.findByProductIdAndMemberId(cartDeleteForm.productId(), member.getId())) + .willReturn(Optional.of(cart)); + + // when + Long deletedProductId = cartService.deleteCartItem(cartDeleteForm, member); + + // then + assertThat(deletedProductId).isEqualTo(product.getId()); + verify(cartRepository).delete(cart); + } + + @Test + @DisplayName("장바구니에 해당 상품이 없는 경우 예외 발생") + void deleteCartItem_WithProductNotInCart_ThrowsCartException() { + // given + CartDeleteForm cartDeleteForm = new CartDeleteForm(999L); + given(cartRepository.findByProductIdAndMemberId(cartDeleteForm.productId(), member.getId())) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> cartService.deleteCartItem(cartDeleteForm, member)) + .isInstanceOf(CartException.class) + .satisfies(exception -> { + CartException cartException = (CartException) exception; + assertThat(cartException.getCode()).isEqualTo(CartErrorCode.PRODUCT_NOT_FOUND_IN_CART.getCode()); + }); + } +} \ No newline at end of file diff --git a/backend/src/test/java/com/example/backend/domain/member/controller/MemberControllerTest.java b/backend/src/test/java/com/example/backend/domain/member/controller/MemberControllerTest.java new file mode 100644 index 00000000..b9607cb4 --- /dev/null +++ b/backend/src/test/java/com/example/backend/domain/member/controller/MemberControllerTest.java @@ -0,0 +1,954 @@ +package com.example.backend.domain.member.controller; + +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +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.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +import com.example.backend.domain.common.Address; +import com.example.backend.domain.member.conveter.MemberConverter; +import com.example.backend.domain.member.dto.MemberDto; +import com.example.backend.domain.member.dto.MemberInfoResponse; +import com.example.backend.domain.member.dto.MemberModifyForm; +import com.example.backend.domain.member.dto.MemberSignupForm; +import com.example.backend.domain.member.dto.PasswordChangeForm; +import com.example.backend.domain.member.entity.Member; +import com.example.backend.domain.member.entity.MemberStatus; +import com.example.backend.domain.member.entity.Role; +import com.example.backend.domain.member.exception.MemberErrorCode; +import com.example.backend.domain.member.exception.MemberException; +import com.example.backend.domain.member.service.MemberDeleteService; +import com.example.backend.domain.member.service.MemberService; +import com.example.backend.global.auth.model.CustomUserDetails; +import com.example.backend.global.auth.service.CookieService; +import com.example.backend.global.config.CorsConfig; +import com.example.backend.global.config.TestSecurityConfig; +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; + +@WebMvcTest(MemberController.class) +@Import({TestSecurityConfig.class, CorsConfig.class}) +@Slf4j +class MemberControllerTest { + @MockitoBean + MemberService memberService; + + @MockitoBean + MemberDeleteService memberDeleteService; + + @MockitoBean + CookieService cookieService; + + @Autowired + private MockMvc mockMvc; + + @Autowired + ObjectMapper objectMapper; + + @DisplayName("회원가입 성공 테스트") + @Test + void signup_success() throws Exception { + //given + MemberSignupForm givenMemberSignupForm = MemberSignupForm.builder() + .username("testEmail@naver.com") + .nickname("testNickName") + .password("!testPassword1234") + .passwordCheck("!testPassword1234") + .city("testCity") + .detail("testDetail") + .country("testCountry") + .district("testDistrict") + .build(); + + doNothing().when(memberService).signup(givenMemberSignupForm.username(), givenMemberSignupForm.nickname(), + givenMemberSignupForm.password(), givenMemberSignupForm.city(), + givenMemberSignupForm.district(), givenMemberSignupForm.country(), givenMemberSignupForm.detail()); + + //when + ResultActions resultActions = mockMvc.perform(post("/api/v1/members/join") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(givenMemberSignupForm))); + + //then + resultActions.andExpect(status().isCreated()); + } + + @DisplayName("회원가입 이메일 유효성 검사 실패 테스트") + @Test + void signup_username_valid_username_fail() throws Exception { + //given + MemberSignupForm givenMemberSignupForm = MemberSignupForm.builder() + .username("test") + .nickname("testNickName") + .password("!testPassword1234") + .passwordCheck("!testPassword1234") + .city("testCity") + .detail("testDetail") + .country("testCountry") + .district("testDistrict") + .build(); + + doNothing().when(memberService).signup(givenMemberSignupForm.username(), givenMemberSignupForm.nickname(), + givenMemberSignupForm.password(), givenMemberSignupForm.city(), + givenMemberSignupForm.district(), givenMemberSignupForm.country(), + givenMemberSignupForm.detail()); + + //when + ResultActions resultActions = mockMvc.perform(post("/api/v1/members/join") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(givenMemberSignupForm))); + + //then + resultActions.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("400-1")) + .andExpect(jsonPath("$.errorDetails[0].field").value("username")) + .andExpect(jsonPath("$.errorDetails[0].reason").value("유효하지 않은 이메일 입니다.")); + } + + @DisplayName("회원가입 닉네임 유효성 검사 실패 테스트") + @Test + void signup_nickname_valid_nickname_fail() throws Exception { + //given + MemberSignupForm givenMemberSignupForm = MemberSignupForm.builder() + .username("test@naver.com") + .nickname("") + .password("!testPassword1234") + .passwordCheck("!testPassword1234") + .city("testCity") + .detail("testDetail") + .country("testCountry") + .district("testDistrict") + .build(); + + doNothing().when(memberService).signup(givenMemberSignupForm.username(), givenMemberSignupForm.nickname(), + givenMemberSignupForm.password(), givenMemberSignupForm.city(), + givenMemberSignupForm.district(), givenMemberSignupForm.country(), + givenMemberSignupForm.detail()); + + //when + ResultActions resultActions = mockMvc.perform(post("/api/v1/members/join") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(givenMemberSignupForm))); + + //then + resultActions.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("400-1")) + .andExpect(jsonPath("$.errorDetails[0].field").value("nickname")) + .andExpect(jsonPath("$.errorDetails[0].reason").value("유효하지 않은 회원 이름 입니다.")); + } + + @DisplayName("회원가입 비밀번호 유효성 검사 실패 테스트") + @Test + void signup_password_valid_password_fail() throws Exception { + //given + MemberSignupForm givenMemberSignupForm = MemberSignupForm.builder() + .username("test@naver.com") + .nickname("testNickname") + .password("testPassword1234") + .passwordCheck("!testPassword1234") + .city("testCity") + .detail("testDetail") + .country("testCountry") + .district("testDistrict") + .build(); + + doNothing().when(memberService).signup(givenMemberSignupForm.username(), givenMemberSignupForm.nickname(), + givenMemberSignupForm.password(), givenMemberSignupForm.city(), + givenMemberSignupForm.district(), givenMemberSignupForm.country(), + givenMemberSignupForm.detail()); + + //when + ResultActions resultActions = mockMvc.perform(post("/api/v1/members/join") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(givenMemberSignupForm))); + + //then + resultActions.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("400-1")) + .andExpect(jsonPath("$.errorDetails[0].field").value("password")) + .andExpect(jsonPath("$.errorDetails[0].reason") + .value("공백 없이 비밀번호는 최소 8자리, 최대 20자리이며 대소문자, 숫자, 특수문자 1개씩 필수 입력해야 합니다.")); + } + + @DisplayName("회원가입 비밀번호 매치 실패 테스트") + @Test + void signup_password_match_fail() throws Exception { + //given + MemberSignupForm givenMemberSignupForm = MemberSignupForm.builder() + .username("test@naver.com") + .nickname("testNickname") + .password("!testPassword1234") + .passwordCheck("!testPassword124") + .city("testCity") + .detail("testDetail") + .country("testCountry") + .district("testDistrict") + .build(); + + doNothing().when(memberService).signup(givenMemberSignupForm.username(), givenMemberSignupForm.nickname(), + givenMemberSignupForm.password(), givenMemberSignupForm.city(), + givenMemberSignupForm.district(), givenMemberSignupForm.country(), + givenMemberSignupForm.detail()); + + //when + ResultActions resultActions = mockMvc.perform(post("/api/v1/members/join") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(givenMemberSignupForm))); + + //then + resultActions.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("400-1")) + .andExpect(jsonPath("$.errorDetails[0].field").value("memberSignupForm")) + .andExpect(jsonPath("$.errorDetails[0].reason") + .value("비밀번호와 비밀번호 확인이 일치하지 않습니다.")); + } + + @DisplayName("회원가입 도시 유효성 검사 실패 테스트") + @Test + void signup_city_not_blank_fail() throws Exception { + //given + MemberSignupForm givenMemberSignupForm = MemberSignupForm.builder() + .username("test@naver.com") + .nickname("testNickname") + .password("!testPassword1234") + .passwordCheck("!testPassword1234") + .city("") + .detail("testDetail") + .country("testCountry") + .district("testDistrict") + .build(); + + doNothing().when(memberService).signup(givenMemberSignupForm.username(), givenMemberSignupForm.nickname(), + givenMemberSignupForm.password(), givenMemberSignupForm.city(), + givenMemberSignupForm.district(), givenMemberSignupForm.country(), + givenMemberSignupForm.detail()); + + //when + ResultActions resultActions = mockMvc.perform(post("/api/v1/members/join") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(givenMemberSignupForm))); + + //then + resultActions.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("400-1")) + .andExpect(jsonPath("$.errorDetails[0].field").value("city")) + .andExpect(jsonPath("$.errorDetails[0].reason") + .value("도시는 필수 항목 입니다.")); + } + + @DisplayName("회원가입 상세주소 유효성 검사 실패 테스트") + @Test + void signup_detail_not_blank_fail() throws Exception { + //given + MemberSignupForm givenMemberSignupForm = MemberSignupForm.builder() + .username("test@naver.com") + .nickname("testNickname") + .password("!testPassword1234") + .passwordCheck("!testPassword1234") + .city("testCity") + .detail("") + .country("testCountry") + .district("testDistrict") + .build(); + + doNothing().when(memberService).signup(givenMemberSignupForm.username(), givenMemberSignupForm.nickname(), + givenMemberSignupForm.password(), givenMemberSignupForm.city(), + givenMemberSignupForm.district(), givenMemberSignupForm.country(), + givenMemberSignupForm.detail()); + + //when + ResultActions resultActions = mockMvc.perform(post("/api/v1/members/join") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(givenMemberSignupForm))); + + //then + resultActions.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("400-1")) + .andExpect(jsonPath("$.errorDetails[0].field").value("detail")) + .andExpect(jsonPath("$.errorDetails[0].reason") + .value("상세 주소는 필수 항목 입니다.")); + } + + @DisplayName("회원가입 도로명 주소 유효성 검사 실패 테스트") + @Test + void signup_country_not_blank_fail() throws Exception { + //given + MemberSignupForm givenMemberSignupForm = MemberSignupForm.builder() + .username("test@naver.com") + .nickname("testNickname") + .password("!testPassword1234") + .passwordCheck("!testPassword1234") + .city("testCity") + .detail("testDetail") + .country("") + .district("testDistrict") + .build(); + + doNothing().when(memberService).signup(givenMemberSignupForm.username(), givenMemberSignupForm.nickname(), + givenMemberSignupForm.password(), givenMemberSignupForm.city(), + givenMemberSignupForm.district(), givenMemberSignupForm.country(), + givenMemberSignupForm.detail()); + + //when + ResultActions resultActions = mockMvc.perform(post("/api/v1/members/join") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(givenMemberSignupForm))); + + //then + resultActions.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("400-1")) + .andExpect(jsonPath("$.errorDetails[0].field").value("country")) + .andExpect(jsonPath("$.errorDetails[0].reason") + .value("도로명 주소는 필수 항목 입니다.")); + } + + @DisplayName("회원가입 지역 구 유효성 검사 실패 테스트") + @Test + void signup_district_not_blank_fail() throws Exception { + //given + MemberSignupForm givenMemberSignupForm = MemberSignupForm.builder() + .username("test@naver.com") + .nickname("testNickname") + .password("!testPassword1234") + .passwordCheck("!testPassword1234") + .city("testCity") + .detail("testDetail") + .country("testCountry") + .district("") + .build(); + + doNothing().when(memberService).signup(givenMemberSignupForm.username(), givenMemberSignupForm.nickname(), + givenMemberSignupForm.password(), givenMemberSignupForm.city(), + givenMemberSignupForm.district(), givenMemberSignupForm.country(), + givenMemberSignupForm.detail()); + + //when + ResultActions resultActions = mockMvc.perform(post("/api/v1/members/join") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(givenMemberSignupForm))); + + //then + resultActions.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("400-1")) + .andExpect(jsonPath("$.errorDetails[0].field").value("district")) + .andExpect(jsonPath("$.errorDetails[0].reason") + .value("지역 구는 필수 항목 입니다.")); + } + + @DisplayName("회원가입시 닉네임 중복 검사 실패 테스트") + @Test + void signup_nickname_exists_fail() throws Exception { + //given + MemberSignupForm givenMemberSignupForm = MemberSignupForm.builder() + .username("test@naver.com") + .nickname("testNickname") + .password("!testPassword1234") + .passwordCheck("!testPassword1234") + .city("testCity") + .detail("testDetail") + .country("testCountry") + .district("testDistrict") + .build(); + + doThrow(new MemberException(MemberErrorCode.EXISTS_NICKNAME)) + .when(memberService).signup(any(String.class), any(String.class), + any(String.class), any(String.class), any(String.class), + any(String.class), any(String.class)); + + //when + ResultActions resultActions = mockMvc.perform(post("/api/v1/members/join") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(givenMemberSignupForm))); + + //then + resultActions.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("400-2")) + .andExpect(jsonPath("$.message").value("중복된 닉네임 입니다.")); + } + + @DisplayName("회원가입시 이메일 중복 검사 실패 테스트") + @Test + void signup_username_exists_fail() throws Exception { + //given + MemberSignupForm givenMemberSignupForm = MemberSignupForm.builder() + .username("test@naver.com") + .nickname("testNickname") + .password("!testPassword1234") + .passwordCheck("!testPassword1234") + .city("testCity") + .detail("testDetail") + .country("testCountry") + .district("testDistrict") + .build(); + + doThrow(new MemberException(MemberErrorCode.EXISTS_USERNAME)) + .when(memberService).signup(any(String.class), any(String.class), + any(String.class), any(String.class), any(String.class), + any(String.class), any(String.class)); + + //when + ResultActions resultActions = mockMvc.perform(post("/api/v1/members/join") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(givenMemberSignupForm))); + + //then + resultActions.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("400-1")) + .andExpect(jsonPath("$.message").value("중복된 이메일 입니다.")); + } + + @Test + @DisplayName("회원 정보 조회") + void getMemberInfo() throws Exception { + // given + Address address = Address.builder() + .city("testCity") + .district("testDistrict") + .country("testCountry") + .detail("testDetail") + .build(); + Member member = Member.builder() + .username("test@naver.com") + .nickname("testNickname") + .memberStatus(MemberStatus.ACTIVE) + .role(Role.ROLE_USER) + .address(address) + .build(); + CustomUserDetails customUserDetails = new CustomUserDetails(member); + + // Authentication 설정 + UsernamePasswordAuthenticationToken authenticationToken = + new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities()); + + // when + ResultActions resultActions = mockMvc.perform(get("/api/v1/members") + .with(SecurityMockMvcRequestPostProcessors.authentication(authenticationToken))); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.username").value("test@naver.com")) + .andExpect(jsonPath("$.data.nickname").value("testNickname")) + .andExpect(jsonPath("$.data.address.city").value("testCity")) + .andExpect(jsonPath("$.data.address.district").value("testDistrict")) + .andExpect(jsonPath("$.data.address.country").value("testCountry")) + .andExpect(jsonPath("$.data.address.detail").value("testDetail")) + .andExpect(jsonPath("$.success").value(true)); + + } + + @Test + @DisplayName("회원 정보 수정 성공") + void modify_success() throws Exception { + // given + Address address = Address.builder() + .city("testCity") + .district("testDistrict") + .country("testCountry") + .detail("testDetail") + .build(); + Member member = Member.builder() + .username("test@naver.com") + .nickname("testNickname") + .memberStatus(MemberStatus.ACTIVE) + .role(Role.ROLE_USER) + .address(address) + .build(); + CustomUserDetails customUserDetails = new CustomUserDetails(member); + + // Authentication 설정 + UsernamePasswordAuthenticationToken authenticationToken = + new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities()); + + MemberModifyForm memberModifyForm = MemberModifyForm.builder() + .nickname("updatedNickname") + .city("updatedCity") + .district("updatedDistrict") + .country("updatedCountry") + .detail("updatedDetail") + .build(); + + Member updatedMember = MemberConverter.of(member.toModel(), memberModifyForm); + MemberInfoResponse response = MemberConverter.from(updatedMember); + when(memberService.modify(any(MemberDto.class), any(MemberModifyForm.class))).thenReturn(response); + + // when + ResultActions resultActions = mockMvc.perform(patch("/api/v1/members") + .with(SecurityMockMvcRequestPostProcessors.authentication(authenticationToken)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(memberModifyForm))); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.username").value("test@naver.com")) + .andExpect(jsonPath("$.data.nickname").value("updatedNickname")) + .andExpect(jsonPath("$.data.address.city").value("updatedCity")) + .andExpect(jsonPath("$.data.address.district").value("updatedDistrict")) + .andExpect(jsonPath("$.data.address.country").value("updatedCountry")) + .andExpect(jsonPath("$.data.address.detail").value("updatedDetail")) + .andExpect(jsonPath("$.success").value(true)); + } + + @Test + @DisplayName("회원 정보 수정 실패 - 중복된 닉네임일 경우") + void modify_fail_nickname_already_exists() throws Exception { + // given + Address address = Address.builder() + .city("testCity") + .district("testDistrict") + .country("testCountry") + .detail("testDetail") + .build(); + Member member = Member.builder() + .username("test@naver.com") + .nickname("testNickname") + .memberStatus(MemberStatus.ACTIVE) + .role(Role.ROLE_USER) + .address(address) + .build(); + CustomUserDetails customUserDetails = new CustomUserDetails(member); + + // Authentication 설정 + UsernamePasswordAuthenticationToken authenticationToken = + new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities()); + + MemberModifyForm memberModifyForm = MemberModifyForm.builder() + .nickname("duplicateNickname") + .city("updatedCity") + .district("updatedDistrict") + .country("updatedCountry") + .detail("updatedDetail") + .build(); + + doThrow(new MemberException(MemberErrorCode.EXISTS_NICKNAME)) + .when(memberService) + .modify(any(MemberDto.class), any(MemberModifyForm.class)); + + // when + ResultActions resultActions = mockMvc.perform(patch("/api/v1/members") + .with(SecurityMockMvcRequestPostProcessors.authentication(authenticationToken)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(memberModifyForm))); + + // then + resultActions.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("400-2")) + .andExpect(jsonPath("$.message").value("중복된 닉네임 입니다.")); + } + + @Test + @DisplayName("회원 정보 수정 실패 - 닉네임 유효성 검사 실패") + void modify_fail_nickname_not_valid() throws Exception { + // given + Address address = Address.builder() + .city("testCity") + .district("testDistrict") + .country("testCountry") + .detail("testDetail") + .build(); + Member member = Member.builder() + .username("test@naver.com") + .nickname("testNickname") + .memberStatus(MemberStatus.ACTIVE) + .role(Role.ROLE_USER) + .address(address) + .build(); + CustomUserDetails customUserDetails = new CustomUserDetails(member); + + // Authentication 설정 + UsernamePasswordAuthenticationToken authenticationToken = + new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities()); + + MemberModifyForm memberModifyForm = MemberModifyForm.builder() + .nickname("") + .city("updatedCity") + .district("updatedDistrict") + .country("updatedCountry") + .detail("updatedDetail") + .build(); + + // when + ResultActions resultActions = mockMvc.perform(patch("/api/v1/members") + .with(SecurityMockMvcRequestPostProcessors.authentication(authenticationToken)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(memberModifyForm))); + + // then + resultActions.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("400-1")) + .andExpect(jsonPath("$.errorDetails[0].field").value("nickname")) + .andExpect(jsonPath("$.errorDetails[0].reason").value("유효하지 않은 회원 이름 입니다.")); + } + + @Test + @DisplayName("회원 정보 수정 실패 - 도시 유효성 검사 실패") + void modify_fail_city_not_valid() throws Exception { + // given + Address address = Address.builder() + .city("testCity") + .district("testDistrict") + .country("testCountry") + .detail("testDetail") + .build(); + Member member = Member.builder() + .username("test@naver.com") + .nickname("testNickname") + .memberStatus(MemberStatus.ACTIVE) + .role(Role.ROLE_USER) + .address(address) + .build(); + CustomUserDetails customUserDetails = new CustomUserDetails(member); + + // Authentication 설정 + UsernamePasswordAuthenticationToken authenticationToken = + new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities()); + + MemberModifyForm memberModifyForm = MemberModifyForm.builder() + .nickname("updatedNickname") + .city("") + .district("updatedDistrict") + .country("updatedCountry") + .detail("updatedDetail") + .build(); + + // when + ResultActions resultActions = mockMvc.perform(patch("/api/v1/members") + .with(SecurityMockMvcRequestPostProcessors.authentication(authenticationToken)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(memberModifyForm))); + + // then + resultActions.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("400-1")) + .andExpect(jsonPath("$.errorDetails[0].field").value("city")) + .andExpect(jsonPath("$.errorDetails[0].reason").value("도시는 필수 항목 입니다.")); + } + + @Test + @DisplayName("회원 정보 수정 실패 - 지역 구 유효성 검사 실패") + void modify_fail_district_not_valid() throws Exception { + // given + Address address = Address.builder() + .city("testCity") + .district("testDistrict") + .country("testCountry") + .detail("testDetail") + .build(); + Member member = Member.builder() + .username("test@naver.com") + .nickname("testNickname") + .memberStatus(MemberStatus.ACTIVE) + .role(Role.ROLE_USER) + .address(address) + .build(); + CustomUserDetails customUserDetails = new CustomUserDetails(member); + + // Authentication 설정 + UsernamePasswordAuthenticationToken authenticationToken = + new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities()); + + MemberModifyForm memberModifyForm = MemberModifyForm.builder() + .nickname("updatedNickname") + .city("updatedCity") + .district("") + .country("updatedCountry") + .detail("updatedDetail") + .build(); + + // when + ResultActions resultActions = mockMvc.perform(patch("/api/v1/members") + .with(SecurityMockMvcRequestPostProcessors.authentication(authenticationToken)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(memberModifyForm))); + + // then + resultActions.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("400-1")) + .andExpect(jsonPath("$.errorDetails[0].field").value("district")) + .andExpect(jsonPath("$.errorDetails[0].reason").value("지역 구는 필수 항목 입니다.")); + } + + @Test + @DisplayName("회원 정보 수정 실패 - 도로명 주소 유효성 검사 실패") + void modify_fail_country_not_valid() throws Exception { + // given + Address address = Address.builder() + .city("testCity") + .district("testDistrict") + .country("testCountry") + .detail("testDetail") + .build(); + Member member = Member.builder() + .username("test@naver.com") + .nickname("testNickname") + .memberStatus(MemberStatus.ACTIVE) + .role(Role.ROLE_USER) + .address(address) + .build(); + CustomUserDetails customUserDetails = new CustomUserDetails(member); + + // Authentication 설정 + UsernamePasswordAuthenticationToken authenticationToken = + new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities()); + + MemberModifyForm memberModifyForm = MemberModifyForm.builder() + .nickname("updatedNickname") + .city("updatedCity") + .district("updatedDistrict") + .country("") + .detail("updatedDetail") + .build(); + + // when + ResultActions resultActions = mockMvc.perform(patch("/api/v1/members") + .with(SecurityMockMvcRequestPostProcessors.authentication(authenticationToken)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(memberModifyForm))); + + // then + resultActions.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("400-1")) + .andExpect(jsonPath("$.errorDetails[0].field").value("country")) + .andExpect(jsonPath("$.errorDetails[0].reason").value("도로명 주소는 필수 항목 입니다.")); + } + + @Test + @DisplayName("회원 정보 수정 실패 - 상세 주소 유효성 검사 실패") + void modify_fail_detail_not_valid() throws Exception { + // given + Address address = Address.builder() + .city("testCity") + .district("testDistrict") + .country("testCountry") + .detail("testDetail") + .build(); + Member member = Member.builder() + .username("test@naver.com") + .nickname("testNickname") + .memberStatus(MemberStatus.ACTIVE) + .role(Role.ROLE_USER) + .address(address) + .build(); + CustomUserDetails customUserDetails = new CustomUserDetails(member); + + // Authentication 설정 + UsernamePasswordAuthenticationToken authenticationToken = + new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities()); + + MemberModifyForm memberModifyForm = MemberModifyForm.builder() + .nickname("updatedNickname") + .city("updatedCity") + .district("updatedDistrict") + .country("updatedCountry") + .detail("") + .build(); + + // when + ResultActions resultActions = mockMvc.perform(patch("/api/v1/members") + .with(SecurityMockMvcRequestPostProcessors.authentication(authenticationToken)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(memberModifyForm))); + + // then + resultActions.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("400-1")) + .andExpect(jsonPath("$.errorDetails[0].field").value("detail")) + .andExpect(jsonPath("$.errorDetails[0].reason").value("상세 주소는 필수 항목 입니다.")); + } + + @Test + @DisplayName("회원 탈퇴 성공 테스트") + void delete_success() throws Exception { + // given + Address address = Address.builder() + .city("testCity") + .district("testDistrict") + .country("testCountry") + .detail("testDetail") + .build(); + Member member = Member.builder() + .username("test@naver.com") + .nickname("testNickname") + .memberStatus(MemberStatus.ACTIVE) + .role(Role.ROLE_USER) + .address(address) + .build(); + CustomUserDetails customUserDetails = new CustomUserDetails(member); + + // Authentication 설정 + UsernamePasswordAuthenticationToken authenticationToken = + new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities()); + + doNothing().when(memberDeleteService).delete(any(MemberDto.class)); + doNothing().when(cookieService).deleteRefreshTokenFromCookie(any(HttpServletResponse.class)); + doNothing().when(cookieService).deleteRefreshTokenFromCookie(any(HttpServletResponse.class)); + + // when + ResultActions resultActions = mockMvc.perform(delete("/api/v1/members") + .with(SecurityMockMvcRequestPostProcessors.authentication(authenticationToken)) + .contentType(MediaType.APPLICATION_JSON)); + + // then + resultActions.andExpect(status().isNoContent()); + } + + @WithMockUser + @DisplayName("비밀번호 변경 성공 테스트") + @Test + void password_change_success() throws Exception { + //given + Address address = Address.builder() + .city("testCity") + .district("testDistrict") + .country("testCountry") + .detail("testDetail") + .build(); + + Member givenMember = Member.builder() + .username("test@naver.com") + .nickname("testNickname") + .password("!testPassword1234") + .memberStatus(MemberStatus.ACTIVE) + .role(Role.ROLE_USER) + .address(address) + .build(); + + CustomUserDetails customUserDetails = new CustomUserDetails(givenMember); + + // Authentication 설정 + UsernamePasswordAuthenticationToken authenticationToken = + new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities()); + + PasswordChangeForm givenPasswordChangeForm = PasswordChangeForm.builder() + .originalPassword(givenMember.getPassword()) + .password("!changePassword1234") + .passwordCheck("!changePassword1234") + .build(); + + doNothing().when(memberService) + .passwordChange(givenMember.getPassword(), givenPasswordChangeForm.password(), givenMember); + + //when + ResultActions resultActions = mockMvc.perform(patch("/api/v1/members/password") + .with(SecurityMockMvcRequestPostProcessors.authentication(authenticationToken)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(givenPasswordChangeForm))); + + //then + resultActions.andExpect(status().isOk()); + } + + @WithMockUser + @DisplayName("비밀번호 변경시 비밀번호 유효성 검사 실패 테스트") + @Test + void password_change_password_valid_success() throws Exception { + //given + Address address = Address.builder() + .city("testCity") + .district("testDistrict") + .country("testCountry") + .detail("testDetail") + .build(); + + Member givenMember = Member.builder() + .username("test@naver.com") + .nickname("testNickname") + .password("!testPassword1234") + .memberStatus(MemberStatus.ACTIVE) + .role(Role.ROLE_USER) + .address(address) + .build(); + + CustomUserDetails customUserDetails = new CustomUserDetails(givenMember); + + // Authentication 설정 + UsernamePasswordAuthenticationToken authenticationToken = + new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities()); + + PasswordChangeForm givenPasswordChangeForm = PasswordChangeForm.builder() + .originalPassword(givenMember.getPassword()) + .password("changePassword1234") + .passwordCheck("!changePassword1234") + .build(); + + doNothing().when(memberService) + .passwordChange(givenMember.getPassword(), givenPasswordChangeForm.password(), givenMember); + + //when + ResultActions resultActions = mockMvc.perform(patch("/api/v1/members/password") + .with(SecurityMockMvcRequestPostProcessors.authentication(authenticationToken)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(givenPasswordChangeForm))); + + //then + resultActions.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("400-1")) + .andExpect(jsonPath("$.errorDetails[0].field").value("password")) + .andExpect(jsonPath("$.errorDetails[0].reason") + .value("공백 없이 비밀번호는 최소 8자리, 최대 20자리이며 대소문자, 숫자, 특수문자 1개씩 필수 입력해야 합니다.")); + } + + @DisplayName("비밀번호 변경시 비밀번호 매치 실패 테스트") + @Test + void password_not_match_success() throws Exception { + //given + Address address = Address.builder() + .city("testCity") + .district("testDistrict") + .country("testCountry") + .detail("testDetail") + .build(); + + Member givenMember = Member.builder() + .username("test@naver.com") + .nickname("testNickname") + .password("!testPassword1234") + .memberStatus(MemberStatus.ACTIVE) + .role(Role.ROLE_USER) + .address(address) + .build(); + + CustomUserDetails customUserDetails = new CustomUserDetails(givenMember); + + // Authentication 설정 + UsernamePasswordAuthenticationToken authenticationToken = + new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities()); + + PasswordChangeForm givenPasswordChangeForm = PasswordChangeForm.builder() + .originalPassword(givenMember.getPassword()) + .password("!changePassword12345") + .passwordCheck("!changePassword1234") + .build(); + + doNothing().when(memberService) + .passwordChange(givenMember.getPassword(), givenPasswordChangeForm.password(), givenMember); + + //when + ResultActions resultActions = mockMvc.perform(patch("/api/v1/members/password") + .with(SecurityMockMvcRequestPostProcessors.authentication(authenticationToken)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(givenPasswordChangeForm))); + + //then + resultActions.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("400-1")) + .andExpect(jsonPath("$.errorDetails[0].field").value("passwordChangeForm")) + .andExpect(jsonPath("$.errorDetails[0].reason") + .value("비밀번호와 비밀번호 확인이 일치하지 않습니다.")); + } +} \ No newline at end of file diff --git a/backend/src/test/java/com/example/backend/domain/member/repository/MemberRepositoryTest.java b/backend/src/test/java/com/example/backend/domain/member/repository/MemberRepositoryTest.java new file mode 100644 index 00000000..c356aba4 --- /dev/null +++ b/backend/src/test/java/com/example/backend/domain/member/repository/MemberRepositoryTest.java @@ -0,0 +1,147 @@ +package com.example.backend.domain.member.repository; + +import static org.assertj.core.api.Assertions.*; + +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.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; + +import com.example.backend.domain.common.Address; +import com.example.backend.domain.member.entity.Member; +import com.example.backend.domain.member.entity.MemberStatus; +import com.example.backend.domain.member.entity.Role; +import com.example.backend.global.config.JpaAuditingConfig; + +@DataJpaTest +@Import(JpaAuditingConfig.class) +class MemberRepositoryTest { + private final MemberRepository memberRepository; + + @Autowired + MemberRepositoryTest(MemberRepository memberRepository) { + this.memberRepository = memberRepository; + } + + @DisplayName("회원 저장 성공 테스트") + @Test + void save_success() { + //given + Address givenAddress = Address.builder() + .city("testCity") + .detail("testDetail") + .district("testDitrict") + .country("testCountry") + .build(); + + Member givenMember = Member.builder() + .username("testEmail@naver.com") + .nickname("testNickname") + .password("testPassword") + .memberStatus(MemberStatus.ACTIVE) + .address(givenAddress) + .role(Role.ROLE_USER) + .build(); + + //when + Member savedMember = memberRepository.save(givenMember); + + //then + assertThat(savedMember.getId()).isNotNull(); + assertThat(savedMember.getUsername()).isEqualTo(givenMember.getUsername()); + assertThat(savedMember.getNickname()).isEqualTo(givenMember.getNickname()); + assertThat(savedMember.getPassword()).isEqualTo(givenMember.getPassword()); + assertThat(savedMember.getRole()).isEqualTo(givenMember.getRole()); + assertThat(savedMember.getAddress()).isEqualTo(givenMember.getAddress()); + assertThat(savedMember.getCreatedAt()).isNotNull(); + assertThat(savedMember.getModifiedAt()).isNotNull(); + } + + @DisplayName("회원 조회 성공 테스트") + @Test + void find_id_success() { + //given + Address givenAddress = Address.builder() + .city("testCity1") + .detail("testDetail") + .district("testDitrict") + .country("testCountry") + .build(); + + Member givenMember = Member.builder() + .username("testEmail1@naver.com") + .nickname("testNickname1") + .password("testPassword1") + .address(givenAddress) + .memberStatus(MemberStatus.ACTIVE) + .role(Role.ROLE_USER) + .build(); + + Member savedMember = memberRepository.save(givenMember); + + //when + Member findMember = memberRepository.findById(savedMember.getId()).get(); + + //then + assertThat(findMember).isEqualTo(savedMember); + } + + @DisplayName("회원 이메일 존재하는지 조회 성공 테스트") + @Test + void exists_username_success() { + //given + Address givenAddress = Address.builder() + .city("testCity1") + .detail("testDetail") + .district("testDitrict") + .country("testCountry") + .build(); + + Member givenMember = Member.builder() + .username("testEmail2@naver.com") + .nickname("testNickname2") + .password("testPassword1") + .address(givenAddress) + .memberStatus(MemberStatus.ACTIVE) + .role(Role.ROLE_USER) + .build(); + + Member savedMember = memberRepository.save(givenMember); + + //when + boolean existsByUsername = memberRepository.existsByUsername(savedMember.getUsername()); + + //then + assertThat(existsByUsername).isTrue(); + } + + @DisplayName("회원 닉네임 존재하는지 조회 성공 테스트") + @Test + void exists_nickname_success() { + //given + Address givenAddress = Address.builder() + .city("testCity1") + .detail("testDetail") + .district("testDitrict") + .country("testCountry") + .build(); + + Member givenMember = Member.builder() + .username("testEmail3@naver.com") + .nickname("testNickname3") + .password("testPassword1") + .address(givenAddress) + .memberStatus(MemberStatus.ACTIVE) + .role(Role.ROLE_USER) + .build(); + + Member savedMember = memberRepository.save(givenMember); + + //when + boolean existsByNickname = memberRepository.existsByNickname(savedMember.getNickname()); + + //then + assertThat(existsByNickname).isTrue(); + } +} \ No newline at end of file diff --git a/backend/src/test/java/com/example/backend/domain/member/service/MemberDeleteServiceTest.java b/backend/src/test/java/com/example/backend/domain/member/service/MemberDeleteServiceTest.java new file mode 100644 index 00000000..fd81b65a --- /dev/null +++ b/backend/src/test/java/com/example/backend/domain/member/service/MemberDeleteServiceTest.java @@ -0,0 +1,58 @@ +package com.example.backend.domain.member.service; + +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.DisplayName; +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 com.example.backend.domain.cart.service.CartService; +import com.example.backend.domain.member.dto.MemberDto; +import com.example.backend.domain.member.entity.Member; +import com.example.backend.domain.member.entity.Role; +import com.example.backend.domain.member.repository.MemberRepository; +import com.example.backend.domain.orders.service.OrdersService; +import com.example.backend.global.redis.service.RedisService; + +@ExtendWith(MockitoExtension.class) +class MemberDeleteServiceTest { + + @Mock + private MemberRepository memberRepository; + + @Mock + private RedisService redisService; + + @Mock + private CartService cartService; + + @Mock + private OrdersService ordersService; + + @InjectMocks + private MemberDeleteService memberDeleteService; + + @Test + @DisplayName("회원 탈퇴 성공 테스트") + void delete_success() { + // given + MemberDto memberDto = MemberDto.builder() + .id(1L) + .username("user@gmail.com") + .nickname("user") + .role(Role.ROLE_USER) + .build(); + + // when + memberDeleteService.delete(memberDto); + + // then + verify(redisService).delete("user@gmail.com"); + verify(cartService).deleteByMemberId(1L); + verify(ordersService).deleteByMemberId(1L); + verify(memberRepository).delete(any(Member.class)); + } +} \ No newline at end of file diff --git a/backend/src/test/java/com/example/backend/domain/member/service/MemberServiceTest.java b/backend/src/test/java/com/example/backend/domain/member/service/MemberServiceTest.java new file mode 100644 index 00000000..858aa6d7 --- /dev/null +++ b/backend/src/test/java/com/example/backend/domain/member/service/MemberServiceTest.java @@ -0,0 +1,334 @@ +package com.example.backend.domain.member.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import org.junit.jupiter.api.DisplayName; +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 org.springframework.security.crypto.password.PasswordEncoder; + +import com.example.backend.domain.common.Address; +import com.example.backend.domain.member.conveter.MemberConverter; +import com.example.backend.domain.member.dto.MemberDto; +import com.example.backend.domain.member.dto.MemberInfoResponse; +import com.example.backend.domain.member.dto.MemberModifyForm; +import com.example.backend.domain.member.dto.MemberSignupForm; +import com.example.backend.domain.member.entity.Member; +import com.example.backend.domain.member.entity.MemberStatus; +import com.example.backend.domain.member.entity.Role; +import com.example.backend.domain.member.exception.MemberErrorCode; +import com.example.backend.domain.member.exception.MemberException; +import com.example.backend.domain.member.repository.MemberRepository; +import com.example.backend.global.mail.service.MailService; +import com.example.backend.global.redis.service.RedisService; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.extern.slf4j.Slf4j; + +@ExtendWith(MockitoExtension.class) +@Slf4j +class MemberServiceTest { + @Mock + private MemberRepository memberRepository; + + @Mock + private RedisService redisService; + + @Mock + private MailService mailService; + + @Mock + private PasswordEncoder passwordEncoder; + + @Mock + private ObjectMapper objectMapper; + + @InjectMocks + private MemberService memberService; + + @DisplayName("회원가입 성공 테스트") + @Test + void signup_success() { + //given + MemberSignupForm givenMemberSignupForm = MemberSignupForm.builder() + .username("testEmail@naver.com") + .nickname("testNickName") + .password("!testPassword1234") + .passwordCheck("!testPassword1234") + .city("testCity") + .detail("testDetail") + .country("testCountry") + .district("testDistrict") + .build(); + + Address givenAddress = Address.builder() + .city(givenMemberSignupForm.city()) + .detail(givenMemberSignupForm.detail()) + .country(givenMemberSignupForm.country()) + .district(givenMemberSignupForm.district()) + .build(); + + MemberDto givenMember = MemberDto.builder() + .username(givenMemberSignupForm.username()) + .nickname(givenMemberSignupForm.nickname()) + .password(givenMemberSignupForm.password()) + .address(givenAddress) + .role(Role.ROLE_USER) + .build(); + + Member givenMemberEntity = Member.from(givenMember); + + given(passwordEncoder.encode(givenMemberSignupForm.password())).willReturn( + givenMemberSignupForm.password()); + given(memberRepository.existsByNickname(givenMember.nickname())).willReturn(false); + given(memberRepository.existsByUsername(givenMember.username())).willReturn(false); + given(memberRepository.save(any(Member.class))).willReturn(givenMemberEntity); + + //when + memberService.signup(givenMemberSignupForm.username(), givenMemberSignupForm.nickname(), + givenMemberSignupForm.password(), givenMemberSignupForm.city(), givenMemberSignupForm.district(), + givenMemberSignupForm.country(), givenMemberSignupForm.detail()); + + //then + verify(passwordEncoder, times(1)).encode(givenMemberSignupForm.password()); + verify(memberRepository, times(1)).existsByNickname(givenMember.nickname()); + verify(memberRepository, times(1)).existsByUsername(givenMember.username()); + verify(memberRepository, times(1)).save(any(Member.class)); + } + + @DisplayName("비밀번호 변경 성공 테스트") + @Test + void password_change_success() { + //given + Address givenAddress = Address.builder() + .city("testCity") + .detail("testDetail") + .country("testCountry") + .district("testDistrict") + .build(); + + Member givenMember = Member.builder() + .username("testUsername") + .nickname("testNickname") + .password("!testPassword1234") + .address(givenAddress) + .memberStatus(MemberStatus.ACTIVE) + .role(Role.ROLE_USER) + .build(); + + String changePassword = "!changePassword1234"; + + Member givenChangePasswordMember = Member.builder() + .username("testUsername") + .nickname("testNickname") + .password(changePassword) + .address(givenAddress) + .memberStatus(MemberStatus.ACTIVE) + .role(Role.ROLE_USER) + .build(); + + given(passwordEncoder.matches(any(String.class), any(String.class))).willReturn(true); + given(passwordEncoder.encode(changePassword)).willReturn(changePassword); + given(memberRepository.save(any(Member.class))).willReturn(givenChangePasswordMember); + + //when + memberService.passwordChange(givenMember.getPassword(), changePassword, givenMember); + + //then + verify(passwordEncoder, times(1)).matches(any(String.class), any(String.class)); + verify(passwordEncoder, times(1)).encode(changePassword); + verify(memberRepository, times(1)).save(any(Member.class)); + } + + @DisplayName("비밀번호 변경시 원래 비밀번호와 일치하지 않을 때 실패 테스트") + @Test + void password_not_match_success() { + //given + Address givenAddress = Address.builder() + .city("testCity") + .detail("testDetail") + .country("testCountry") + .district("testDistrict") + .build(); + + Member givenMember = Member.builder() + .username("testUsername") + .nickname("testNickname") + .password("!testPassword1234") + .address(givenAddress) + .memberStatus(MemberStatus.ACTIVE) + .role(Role.ROLE_USER) + .build(); + + String changePassword = "!changePassword12345"; + + given(passwordEncoder.matches(changePassword, givenMember.getPassword())).willReturn(false); + + //when & then + assertThatThrownBy(() -> memberService.passwordChange(changePassword, changePassword, givenMember)) + .isInstanceOf(MemberException.class) + .hasMessage(MemberErrorCode.PASSWORD_NOT_MATCH.getMessage()); + + } + + @DisplayName("회원가입 이메일 중복 실패 테스트") + @Test + void signup_exists_username_fail() { + //given + MemberSignupForm givenMemberSignupForm = MemberSignupForm.builder() + .username("testEmail@naver.com") + .nickname("testNickName") + .password("!testPassword1234") + .passwordCheck("!testPassword1234") + .city("testCity") + .detail("testDetail") + .country("testCountry") + .district("testDistrict") + .build(); + + Address givenAddress = Address.builder() + .city(givenMemberSignupForm.city()) + .detail(givenMemberSignupForm.detail()) + .country(givenMemberSignupForm.country()) + .district(givenMemberSignupForm.district()) + .build(); + + Member givenMember = Member.builder() + .username(givenMemberSignupForm.username()) + .nickname(givenMemberSignupForm.nickname()) + .password(givenMemberSignupForm.password()) + .address(givenAddress) + .role(Role.ROLE_USER) + .build(); + + given(memberRepository.existsByUsername(givenMember.getUsername())).willReturn(true); + given(memberRepository.existsByNickname(givenMember.getNickname())).willReturn(false); + + //when & then + assertThatThrownBy(() -> memberService.signup( + givenMemberSignupForm.username(), givenMemberSignupForm.nickname(), + givenMemberSignupForm.password(), givenMemberSignupForm.city(), givenMemberSignupForm.district(), + givenMemberSignupForm.country(), givenMemberSignupForm.detail())) + .isInstanceOf(MemberException.class) + .hasMessage(MemberErrorCode.EXISTS_USERNAME.getMessage()); + } + + @DisplayName("회원가입 닉네임 중복 실패 테스트") + @Test + void signup_exists_nickname_fail() { + //given + MemberSignupForm givenMemberSignupForm = MemberSignupForm.builder() + .username("testEmail@naver.com") + .nickname("testNickName") + .password("!testPassword1234") + .passwordCheck("!testPassword1234") + .city("testCity") + .detail("testDetail") + .country("testCountry") + .district("testDistrict") + .build(); + + Address givenAddress = Address.builder() + .city(givenMemberSignupForm.city()) + .detail(givenMemberSignupForm.detail()) + .country(givenMemberSignupForm.country()) + .district(givenMemberSignupForm.district()) + .build(); + + MemberDto givenMember = MemberDto.builder() + .username(givenMemberSignupForm.username()) + .nickname(givenMemberSignupForm.nickname()) + .password(givenMemberSignupForm.password()) + .address(givenAddress) + .role(Role.ROLE_USER) + .build(); + + given(memberRepository.existsByUsername(givenMember.username())).willReturn(false); + given(memberRepository.existsByNickname(givenMember.nickname())).willReturn(true); + + //when & then + assertThatThrownBy(() -> memberService.signup( + givenMemberSignupForm.username(), givenMemberSignupForm.nickname(), + givenMemberSignupForm.password(), givenMemberSignupForm.city(), givenMemberSignupForm.district(), + givenMemberSignupForm.country(), givenMemberSignupForm.detail())) + .isInstanceOf(MemberException.class) + .hasMessage(MemberErrorCode.EXISTS_NICKNAME.getMessage()); + } + + @Test + @DisplayName("회원 정보 수정 성공 테스트") + void modify_success() { + // given + Address address = Address.builder() + .city("testCity") + .district("testDistrict") + .country("testCountry") + .detail("testDetail") + .build(); + + MemberDto memberDto = MemberDto.builder() + .nickname("testNickName") + .address(address) + .build(); + + MemberModifyForm memberModifyForm = MemberModifyForm.builder() + .nickname("updatedNickName") + .city("updatedCity") + .district("updatedDistrict") + .country("updatedCountry") + .detail("updatedDetail") + .build(); + + Member member = MemberConverter.of(memberDto, memberModifyForm); + when(memberRepository.existsByNickname(memberModifyForm.nickname())).thenReturn(false); + when(memberRepository.save(any(Member.class))).thenReturn(member); + + // when + MemberInfoResponse result = memberService.modify(memberDto, memberModifyForm); + + // then + assertThat(result).isNotNull(); + assertThat(result).isInstanceOf(MemberInfoResponse.class); + assertThat(result.nickname()).isEqualTo(memberModifyForm.nickname()); + assertThat(result.address().getCity()).isEqualTo(memberModifyForm.city()); + assertThat(result.address().getDistrict()).isEqualTo(memberModifyForm.district()); + assertThat(result.address().getCountry()).isEqualTo(memberModifyForm.country()); + assertThat(result.address().getDetail()).isEqualTo(memberModifyForm.detail()); + } + + @Test + @DisplayName("회원 정보 수정 실패 - 닉네임 중복") + void modify_fail_nickname_already_exists() { + // given + Address address = Address.builder() + .city("testCity") + .district("testDistrict") + .country("testCountry") + .detail("testDetail") + .build(); + + MemberDto memberDto = MemberDto.builder() + .nickname("testNickName") + .address(address) + .build(); + + MemberModifyForm memberModifyForm = MemberModifyForm.builder() + .nickname("updatedNickName") + .city("updatedCity") + .district("updatedDistrict") + .country("updatedCountry") + .detail("updatedDetail") + .build(); + + when(memberRepository.existsByNickname(memberModifyForm.nickname())).thenReturn(true); + + // when & then + assertThatThrownBy(() -> memberService.modify(memberDto, memberModifyForm)) + .isInstanceOf(MemberException.class) + .hasMessage(MemberErrorCode.EXISTS_NICKNAME.getMessage()); + } +} \ No newline at end of file diff --git a/backend/src/test/java/com/example/backend/domain/orders/controller/OrdersControllerTest.java b/backend/src/test/java/com/example/backend/domain/orders/controller/OrdersControllerTest.java new file mode 100644 index 00000000..a31ab13e --- /dev/null +++ b/backend/src/test/java/com/example/backend/domain/orders/controller/OrdersControllerTest.java @@ -0,0 +1,381 @@ +package com.example.backend.domain.orders.controller; + +import com.example.backend.domain.member.entity.Member; +import com.example.backend.domain.member.entity.Role; +import com.example.backend.domain.orders.dto.OrdersForm; +import com.example.backend.domain.orders.dto.OrdersResponse; +import com.example.backend.domain.orders.dto.ProductInfoDto; +import com.example.backend.domain.orders.exception.OrdersErrorCode; +import com.example.backend.domain.orders.exception.OrdersException; +import com.example.backend.domain.orders.service.OrdersService; +import com.example.backend.domain.orders.status.DeliveryStatus; +import com.example.backend.global.auth.model.CustomUserDetails; +import com.example.backend.global.config.CorsConfig; +import com.example.backend.global.config.TestSecurityConfig; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +import static org.mockito.Mockito.*; + +import java.time.ZonedDateTime; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(OrdersController.class) +@Import({TestSecurityConfig.class, CorsConfig.class}) +@WithMockUser() +public class OrdersControllerTest { + + @MockitoBean + private OrdersService ordersService; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private MockMvc mockMvc; + + @Test + @DisplayName("주문 단건 조회 성공") + void findOneSuccessTest() throws Exception { + // given + Long orderId = 1L; + OrdersResponse response = OrdersResponse.builder() + .id(orderId) + .products(List.of( + ProductInfoDto.builder() + .name("상품A") + .price(1000) + .quantity(2) + .imgUrl("http://example.com/productA.jpg") + .build() + )) + .totalPrice(2000) + .status(DeliveryStatus.READY) + .createAt(ZonedDateTime.now()) + .modifiedAt(ZonedDateTime.now()) + .build(); + + when(ordersService.findOne(orderId)).thenReturn(response); + + // when + ResultActions resultActions = mockMvc.perform(get("/api/v1/orders/{id}", orderId) + .contentType(MediaType.APPLICATION_JSON) + ); + + // then + resultActions + .andExpect(handler().handlerType(OrdersController.class)) + .andExpect(handler().methodName("findOne")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.id").value(orderId)) + .andExpect(jsonPath("$.data.totalPrice").value(2000)) + .andExpect(jsonPath("$.data.status").value("READY")) + .andExpect(jsonPath("$.data.products[0].name").value("상품A")) + .andExpect(jsonPath("$.data.products[0].price").value(1000)) + .andExpect(jsonPath("$.data.products[0].quantity").value(2)) + .andExpect(jsonPath("$.data.products[0].imgUrl").value("http://example.com/productA.jpg")) + .andExpect(jsonPath("$.data.createAt").exists()) + .andExpect(jsonPath("$.data.modifiedAt").exists()); + } + + @Test + @DisplayName("주문 단건 조회 실패 - 존재하지 않는 주문") + void findOneFailNotFoundTest() throws Exception { + // given + Long orderId = 999L; + when(ordersService.findOne(orderId)).thenThrow(new OrdersException(OrdersErrorCode.NOT_FOUND)); + + // when + ResultActions resultActions = mockMvc.perform(get("/api/v1/orders/{id}", orderId) + .contentType(MediaType.APPLICATION_JSON) + ); + + // then + resultActions + .andExpect(handler().handlerType(OrdersController.class)) + .andExpect(status().isNotFound()) + .andExpect(handler().methodName("findOne")) + .andExpect(jsonPath("$.code").value(OrdersErrorCode.NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(OrdersErrorCode.NOT_FOUND.getMessage())); + + } + + @Test + @DisplayName("현재 진행 중인 주문 조회 성공") + void currentOrdersSuccessTest() throws Exception { + // given + Long memberId = 1L; + List mockOrderResponses = Arrays.asList( + OrdersResponse.builder() + .id(1L) + .products(List.of( + ProductInfoDto.builder() + .name("상품A") + .price(1000) + .quantity(2) + .imgUrl("http://example.com/productA.jpg") + .build() + )) + .totalPrice(2000) + .status(DeliveryStatus.READY) + .createAt(ZonedDateTime.now()) + .modifiedAt(ZonedDateTime.now()) + .build(), + OrdersResponse.builder() + .id(2L) + .products(List.of( + ProductInfoDto.builder() + .name("상품B") + .price(3000) + .quantity(1) + .imgUrl("http://example.com/productB.jpg") + .build() + )) + .totalPrice(3000) + .status(DeliveryStatus.SHIPPED) + .createAt(ZonedDateTime.now()) + .modifiedAt(ZonedDateTime.now()) + .build() + ); + + // READY 상태인 주문만 필터링 + List readyOrders = mockOrderResponses.stream() + .filter(order -> order.status() == DeliveryStatus.READY) + .collect(Collectors.toList()); + + // 인증된 사용자의 ID로 주문 목록 모킹 + when(ordersService.current(memberId)).thenReturn(readyOrders); + + // when, then + mockMvc.perform(get("/api/v1/orders/current") + .contentType(MediaType.APPLICATION_JSON) + .with(user(createMockCustomUserDetails(memberId)))) + .andExpect(status().isOk()) + .andExpect(handler().handlerType(OrdersController.class)) + .andExpect(handler().methodName("current")) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data.length()").value(1)) + .andExpect(jsonPath("$.data[0].id").value(1L)) + .andExpect(jsonPath("$.data[0].totalPrice").value(2000)) + .andExpect(jsonPath("$.data[0].status").value("READY")); + + } + + @Test + @DisplayName("주문 히스토리 조회 성공") + void historySuccess() throws Exception { + // given + Long memberId = 1L; + List mockOrderResponses = Arrays.asList( + OrdersResponse.builder() + .id(1L) + .products(List.of( + ProductInfoDto.builder() + .name("상품A") + .price(1000) + .quantity(2) + .imgUrl("http://example.com/productA.jpg") + .build() + )) + .totalPrice(2000) + .status(DeliveryStatus.READY) + .createAt(ZonedDateTime.now()) + .modifiedAt(ZonedDateTime.now()) + .build(), + OrdersResponse.builder() + .id(2L) + .products(List.of( + ProductInfoDto.builder() + .name("상품B") + .price(3000) + .quantity(1) + .imgUrl("http://example.com/productB.jpg") + .build() + )) + .totalPrice(3000) + .status(DeliveryStatus.SHIPPED) + .createAt(ZonedDateTime.now().plusHours(1)) + .modifiedAt(ZonedDateTime.now().plusHours(1)) + .build() + ); + + when(ordersService.history(1L)).thenReturn(mockOrderResponses); + + // when, then + mockMvc.perform(get("/api/v1/orders/history") + .contentType(MediaType.APPLICATION_JSON) + .with(user(createMockCustomUserDetails(memberId)))) + .andExpect(status().isOk()) + .andExpect(handler().handlerType(OrdersController.class)) + .andExpect(handler().methodName("history")) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data.length()").value(2)) + .andExpect(jsonPath("$.data[0].id").value(1L)) + .andExpect(jsonPath("$.data[0].totalPrice").value(2000)) + .andExpect(jsonPath("$.data[0].status").value("READY")) + .andExpect(jsonPath("$.data[1].id").value(2L)) + .andExpect(jsonPath("$.data[1].totalPrice").value(3000)) + .andExpect(jsonPath("$.data[1].status").value("SHIPPED")); + + } + + @Test + @DisplayName("주문 생성 성공") + void createOrderSuccessTest() throws Exception { + // given + Long memberId = 1L; + OrdersForm ordersForm = new OrdersForm( + memberId, + "서울", + "강남구", + "대한민국", + "테헤란로 123", + List.of( + new OrdersForm.ProductOrdersRequest(1L, 2), + new OrdersForm.ProductOrdersRequest(2L, 1) + ) + ); + + Long expectedOrderId = 100L; + when(ordersService.create(any(OrdersForm.class), any(Member.class))) + .thenReturn(expectedOrderId); + + // when, then + mockMvc.perform(post("/api/v1/orders") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(ordersForm)) + .with(user(createMockCustomUserDetails(memberId)))) + .andExpect(status().isOk()) + .andExpect(handler().handlerType(OrdersController.class)) + .andExpect(handler().methodName("create")) + .andExpect(jsonPath("$.data").value(expectedOrderId)); + } + + @Test + @DisplayName("주문 생성 실패 - 상품 리스트 비어있음") + void createOrderEmptyProductListFailTest() throws Exception { + // given + Long memberId = 1L; + OrdersForm ordersForm = new OrdersForm( + memberId, + "서울", + "강남구", + "대한민국", + "테헤란로 123", + Collections.emptyList() + ); + + // when, then + mockMvc.perform(post("/api/v1/orders") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(ordersForm)) + .with(user(createMockCustomUserDetails(memberId)))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorDetails[0].field").value("productOrdersRequestList")) + .andExpect(jsonPath("$.errorDetails[0].reason").value("상품 주문 리스트는 비어 있을 수 없습니다.")); + } + + @Test + @DisplayName("주문 생성 실패 - 상품 수량 부적절") + void createOrderInvalidQuantityFailTest() throws Exception { + // given + Long memberId = 1L; + OrdersForm ordersForm = new OrdersForm( + memberId, + "서울", + "강남구", + "대한민국", + "테헤란로 123", + List.of( + new OrdersForm.ProductOrdersRequest(1L, 0) + ) + ); + + // when, then + mockMvc.perform(post("/api/v1/orders") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(ordersForm)) + .with(user(createMockCustomUserDetails(memberId)))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorDetails[0].field").value("productOrdersRequestList[0].quantity")) + .andExpect(jsonPath("$.errorDetails[0].reason").value("수량은 1 이상이어야 합니다.")); + } + + @Test + @DisplayName("주문 생성 실패 - 주소 정보 누락") + void createOrderMissingAddressFailTest() throws Exception { + // given + Long memberId = 1L; + OrdersForm ordersForm = new OrdersForm( + memberId, + "", + "강남구", + "대한민국", + "테헤란로 123", + List.of( + new OrdersForm.ProductOrdersRequest(1L, 2) + ) + ); + + // when, then + mockMvc.perform(post("/api/v1/orders") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(ordersForm)) + .with(user(createMockCustomUserDetails(memberId)))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorDetails[0].field").value("city")) + .andExpect(jsonPath("$.errorDetails[0].reason").value("도시는 필수입니다.")); + } + + @Test + @DisplayName("주문 정상 취소") + void order_cancel_success() throws Exception { + + Long orderId = 1L; + + doNothing().when(ordersService).cancelById(orderId); + + mockMvc.perform(patch("/api/v1/orders/{id}", orderId) + .contentType(MediaType.APPLICATION_JSON) + .with(user(createMockCustomUserDetails(orderId)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isEmpty()) + .andExpect(jsonPath("$.success").value(true)); + + verify(ordersService).cancelById(orderId); + } + + + // 테스트에서 사용할 MockCustomUserDetails 생성 메서드 + private CustomUserDetails createMockCustomUserDetails(Long memberId) { + Member mockMember = Member.builder() + .id(memberId) + .role(Role.ROLE_USER) + .build(); + return new CustomUserDetails(mockMember); + + } + + + + +} \ No newline at end of file diff --git a/backend/src/test/java/com/example/backend/domain/orders/repository/OrdersRepositoryTest.java b/backend/src/test/java/com/example/backend/domain/orders/repository/OrdersRepositoryTest.java new file mode 100644 index 00000000..c7c40f17 --- /dev/null +++ b/backend/src/test/java/com/example/backend/domain/orders/repository/OrdersRepositoryTest.java @@ -0,0 +1,374 @@ +package com.example.backend.domain.orders.repository; + +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalTime; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; + +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.orm.jpa.DataJpaTest; +import org.springframework.transaction.annotation.Transactional; + +import com.example.backend.domain.common.Address; +import com.example.backend.domain.member.entity.Member; +import com.example.backend.domain.member.entity.MemberStatus; +import com.example.backend.domain.member.entity.Role; +import com.example.backend.domain.member.repository.MemberRepository; +import com.example.backend.domain.orders.entity.Orders; +import com.example.backend.domain.orders.status.DeliveryStatus; +import com.example.backend.domain.product.entity.Product; +import com.example.backend.domain.product.repository.ProductRepository; +import com.example.backend.domain.productOrders.entity.ProductOrders; +import com.example.backend.domain.productOrders.repository.ProductOrdersRepository; + +import jakarta.persistence.EntityManager; +import lombok.extern.slf4j.Slf4j; + +@Transactional +@DataJpaTest +@Slf4j +public class OrdersRepositoryTest { + + @Autowired + OrdersRepository ordersRepository; + @Autowired + MemberRepository memberRepository; + @Autowired + ProductRepository productRepository; + @Autowired + ProductOrdersRepository productOrdersRepository; + @Autowired + EntityManager entityManager; + + private Member createMember() { + return Member.builder() + .username("test") + .nickname("test") + .password("123") + .role(Role.ROLE_USER) + .memberStatus(MemberStatus.ACTIVE) + .address(new Address("123 Main St", "New York", "NY", "10001")) + .createdAt(ZonedDateTime.now()) + .modifiedAt(ZonedDateTime.now()) + .build(); + } + + private Product createProduct() { + return Product.builder() + .name("test") + .content("test") + .price(100) + .imgUrl("test") + .quantity(10) + .build(); + } + + private ProductOrders createProductOrders(Product product) { + return ProductOrders.create() + .product(product) + .quantity(2) + .price(100) + .build(); + } + + @Test + @DisplayName("주문 저장 성공") + void saveOrder() { + + // Member 객체 생성 + + Member savedMember = memberRepository.save(createMember()); + + // Product 객체 생성 + Product savedProduct = productRepository.save(createProduct()); + + // ProductOrders 객체 생성 + ProductOrders savedProductOrders = productOrdersRepository.save(createProductOrders(savedProduct)); + + // ProductOrders 목록 준비 + List productOrdersList = new ArrayList<>(); + productOrdersList.add(savedProductOrders); + + // Orders 객체 생성 + Orders orders = Orders.create() + .member(savedMember) + .productOrdersList(productOrdersList) + .address(savedMember.getAddress()) + .build(); + + // 주문 저장 + Orders savedOrder = ordersRepository.save(orders); + + Address address = new Address("123 Main St", "New York", "NY", "10001"); + + // 저장된 주문이 예상한 주문과 동일한지 검증 + assertThat(orders).isEqualTo(savedOrder); + assertThat(orders.getMember()).isEqualTo(savedOrder.getMember()); + assertThat(orders.getProductOrdersList()).isEqualTo(savedOrder.getProductOrdersList()); + assertThat(200).isEqualTo(savedOrder.getTotalPrice()); + assertThat(address.getCity()).isEqualTo(savedOrder.getAddress().getCity()); + assertThat(address.getDistrict()).isEqualTo(savedOrder.getAddress().getDistrict()); + assertThat(address.getCountry()).isEqualTo(savedOrder.getAddress().getCountry()); + assertThat(address.getDetail()).isEqualTo(savedOrder.getAddress().getDetail()); + + } + + @Test + @DisplayName("현재 주문 조회 성공") + void findByMemberIdAndDeliveryStatus() { + Member savedMember = memberRepository.save(createMember()); + log.info("memberId = {}", savedMember.getId()); // memberId: 1 반환 + + Product savedProduct = productRepository.save(createProduct()); + + ProductOrders savedProductOrders = productOrdersRepository.save(createProductOrders(savedProduct)); + + Orders orders = Orders.create() + .member(savedMember) + .productOrdersList(List.of(savedProductOrders)) + .address(savedMember.getAddress()) + .build(); + + Orders save = ordersRepository.save(orders); + + List ordersList = + ordersRepository.findByMemberIdAndDeliveryStatus(save.getMember().getId(), DeliveryStatus.READY); + + log.info("orderList = {}", ordersList); + + log.info("memberId = {}", save.getMember().getId()); // 이것조차 이상 없음 memberId 는 잘 저장됨 + + assertThat(ordersList.size()).isEqualTo(1); + assertThat(ordersList.get(0).getDeliveryStatus()).isEqualTo(DeliveryStatus.READY); + assertThat(ordersList.get(0).getMember().getUsername()).isEqualTo("test"); + } + + @Test + @DisplayName("주문 상태가 READY 인 주문만 조회") + void getOnlyStatusReady() { + + Member savedMember = memberRepository.save(createMember()); + Product savedProduct = productRepository.save(createProduct()); + + Orders orders1 = Orders.create() + .member(savedMember) + .productOrdersList(List.of(createProductOrders(savedProduct))) + .address(savedMember.getAddress()) + .build(); + + Orders orders2 = Orders.create() + .member(savedMember) + .productOrdersList(List.of(createProductOrders(savedProduct))) + .address(savedMember.getAddress()) + .build(); + + orders2.changeStatus(DeliveryStatus.SHIPPED); + + ordersRepository.save(orders1); + ordersRepository.save(orders2); + + ordersRepository.flush(); + entityManager.clear(); + + List ordersList = ordersRepository.findByMemberIdAndDeliveryStatus( + savedMember.getId(), + DeliveryStatus.READY + ); + + assertThat(ordersList.size()).isEqualTo(1); + assertThat(ordersList.get(0).getDeliveryStatus()).isEqualTo(DeliveryStatus.READY); + } + + @Test + @DisplayName("모든 주문 목록 조회 및 수정시간순으로 조회 성공") + void history() { + Member savedMember = memberRepository.save(createMember()); + Product savedProduct = productRepository.save(createProduct()); + + Orders orders1 = Orders.create() + .member(savedMember) + .productOrdersList(List.of(createProductOrders(savedProduct))) + .address(savedMember.getAddress()) + .build(); + + Orders orders2 = Orders.create() + .member(savedMember) + .productOrdersList(List.of(createProductOrders(savedProduct))) + .address(savedMember.getAddress()) + .build(); + + orders2.changeStatus(DeliveryStatus.SHIPPED); + + ordersRepository.save(orders1); + ordersRepository.save(orders2); + + List ordersList = ordersRepository.findAllByMemberIdAndDeliveryStatusOrderByModifiedAt( + savedMember.getId(), + List.of(DeliveryStatus.READY, + DeliveryStatus.SHIPPED) + ); + + assertThat(ordersList.size()).isEqualTo(2); + assertThat(ordersList.get(0).getDeliveryStatus()).isEqualTo(DeliveryStatus.SHIPPED); + } + + @Test + @DisplayName("startTime, endTime 사이의 배송 준비중인 데이터 조회") + void findReadyOrders() { + //given + Member savedMember = memberRepository.save(createMember()); + Product savedProduct = productRepository.save(createProduct()); + + ProductOrders productOrders1 = createProductOrders(savedProduct); + ProductOrders productOrders2 = createProductOrders(savedProduct); + ProductOrders productOrders3 = createProductOrders(savedProduct); + + Orders createOrders1 = Orders.create() + .member(savedMember) + .address(savedMember.getAddress()) + .productOrdersList(List.of(productOrders1, productOrders2, productOrders3)) + .build(); + + Orders createOrders2 = Orders.create() + .member(savedMember) + .address(savedMember.getAddress()) + .productOrdersList(List.of(productOrders1, productOrders2, productOrders3)) + .build(); + + Orders createOrders3 = Orders.create() + .member(savedMember) + .address(savedMember.getAddress()) + .productOrdersList(List.of(productOrders1, productOrders2, productOrders3)) + .build(); + + createOrders3.changeStatus(DeliveryStatus.SHIPPED); + + ordersRepository.saveAll(List.of(createOrders1, createOrders2, createOrders3)); + + ZonedDateTime now = ZonedDateTime.now(); + ZonedDateTime startTime = now.minusDays(1).with(LocalTime.of(14, 0)); + ZonedDateTime endTime = now; + + //when + List ordersList = ordersRepository.findReadyOrders(startTime, endTime); + + //then + Orders orders1 = ordersList.get(0); + Orders orders2 = ordersList.get(1); + + assertThat(ordersList.size()).isEqualTo(2); + assertThat(orders1).isEqualTo(createOrders1); + assertThat(orders2).isEqualTo(createOrders2); + assertThat(orders1.getModifiedAt()).isAfter(startTime); + assertThat(orders1.getModifiedAt()).isBefore(endTime); + assertThat(orders2.getModifiedAt()).isAfter(startTime); + assertThat(orders2.getModifiedAt()).isBefore(endTime); + } + + @Test + @DisplayName("startTime, endTime 사이의 주문 배송 상태 SHIPPED으로 변경") + void bulkUpdateDeliveryStatus() { + //given + Member savedMember = memberRepository.save(createMember()); + Product savedProduct = productRepository.save(createProduct()); + + ProductOrders productOrders1 = createProductOrders(savedProduct); + ProductOrders productOrders2 = createProductOrders(savedProduct); + ProductOrders productOrders3 = createProductOrders(savedProduct); + + Orders createOrders1 = Orders.create() + .member(savedMember) + .address(savedMember.getAddress()) + .productOrdersList(List.of(productOrders1, productOrders2, productOrders3)) + .build(); + + Orders createOrders2 = Orders.create() + .member(savedMember) + .address(savedMember.getAddress()) + .productOrdersList(List.of(productOrders1, productOrders2, productOrders3)) + .build(); + + Orders createOrders3 = Orders.create() + .member(savedMember) + .address(savedMember.getAddress()) + .productOrdersList(List.of(productOrders1, productOrders2, productOrders3)) + .build(); + + createOrders3.changeStatus(DeliveryStatus.SHIPPED); + + ordersRepository.saveAll(List.of(createOrders1, createOrders2, createOrders3)); + + ZonedDateTime now = ZonedDateTime.now(); + ZonedDateTime startTime = now.minusDays(1).with(LocalTime.of(14, 0)); + ZonedDateTime endTime = now; + + //when + ordersRepository.bulkUpdateDeliveryStatus(startTime, endTime); + + //then + entityManager.flush(); + entityManager.clear(); + List ordersList = ordersRepository.findAll(); + + assertThat(ordersList.size()).isEqualTo(3); + assertThat(ordersList).allMatch(order -> + order.getDeliveryStatus() == DeliveryStatus.SHIPPED && + order.getModifiedAt().isAfter(startTime) && + order.getModifiedAt().isBefore(endTime) + ); + } + + @Test + @DisplayName("startTime, endTime 사이의 배송 준비중인 회원 username 조회") + void findUsernameByReady() { + //given + Member savedMember = memberRepository.save(createMember()); + Product savedProduct = productRepository.save(createProduct()); + + ProductOrders productOrders1 = createProductOrders(savedProduct); + ProductOrders productOrders2 = createProductOrders(savedProduct); + ProductOrders productOrders3 = createProductOrders(savedProduct); + + Orders createOrders1 = Orders.create() + .member(savedMember) + .address(savedMember.getAddress()) + .productOrdersList(List.of(productOrders1, productOrders2, productOrders3)) + .build(); + + Orders createOrders2 = Orders.create() + .member(savedMember) + .address(savedMember.getAddress()) + .productOrdersList(List.of(productOrders1, productOrders2, productOrders3)) + .build(); + + Orders createOrders3 = Orders.create() + .member(savedMember) + .address(savedMember.getAddress()) + .productOrdersList(List.of(productOrders1, productOrders2, productOrders3)) + .build(); + + createOrders3.changeStatus(DeliveryStatus.SHIPPED); + + ordersRepository.saveAll(List.of(createOrders1, createOrders2, createOrders3)); + + ZonedDateTime now = ZonedDateTime.now(); + ZonedDateTime startTime = now.minusDays(1).with(LocalTime.of(14, 0)); + ZonedDateTime endTime = now; + + //when + List usernameList = ordersRepository.findUsernameByReady(startTime, endTime); + + //then + String username1 = usernameList.get(0); + String username2 = usernameList.get(1); + + assertThat(usernameList.size()).isEqualTo(2); + assertThat(username1).isEqualTo(createOrders1.getMember().getUsername()); + assertThat(username2).isEqualTo(createOrders2.getMember().getUsername()); + } + +} diff --git a/backend/src/test/java/com/example/backend/domain/orders/service/OrdersServiceTest.java b/backend/src/test/java/com/example/backend/domain/orders/service/OrdersServiceTest.java new file mode 100644 index 00000000..40b3fd6e --- /dev/null +++ b/backend/src/test/java/com/example/backend/domain/orders/service/OrdersServiceTest.java @@ -0,0 +1,328 @@ +package com.example.backend.domain.orders.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; + +import com.example.backend.domain.common.Address; +import com.example.backend.domain.product.exception.ProductErrorCode; +import com.example.backend.domain.product.exception.ProductException; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.DisplayName; +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 com.example.backend.domain.member.entity.Member; +import com.example.backend.domain.member.repository.MemberRepository; +import com.example.backend.domain.orders.dto.OrdersResponse; +import com.example.backend.domain.orders.entity.Orders; +import com.example.backend.domain.orders.exception.OrdersException; +import com.example.backend.domain.orders.repository.OrdersRepository; +import com.example.backend.domain.orders.status.DeliveryStatus; +import com.example.backend.domain.product.entity.Product; +import com.example.backend.domain.productOrders.entity.ProductOrders; +import org.springframework.http.HttpStatus; + +@ExtendWith(MockitoExtension.class) +@Slf4j +class OrdersServiceTest { + + @Mock + OrdersRepository ordersRepository; + @InjectMocks + OrdersService ordersService; + + private Orders mockOrder(Long id, DeliveryStatus status) { + Orders orders = mock(Orders.class); + ZonedDateTime now = ZonedDateTime.now(); + List productOrders = mockProductOrders(); + + lenient().when(orders.getId()).thenReturn(id); + lenient().when(orders.getTotalPrice()).thenReturn(1000); + lenient().when(orders.getDeliveryStatus()).thenReturn(status); + lenient().when(orders.getCreatedAt()).thenReturn(now); + lenient().when(orders.getModifiedAt()).thenReturn(now); + lenient().when(orders.getProductOrdersList()).thenReturn(productOrders); + + return orders; + } + + private List mockProductOrders() { + ProductOrders productOrder = mock(ProductOrders.class); + Product product = mock(Product.class); + + // OrdersResponse에서 실제로 사용하는 필드만 stub + lenient().when(product.getName()).thenReturn("A"); + lenient().when(product.getImgUrl()).thenReturn("http://example.com/productA.jpg"); + lenient().when(product.getQuantity()).thenReturn(10); + lenient().when(productOrder.getProduct()).thenReturn(product); + lenient().when(productOrder.getQuantity()).thenReturn(1); + + return List.of(productOrder); + } + + @Test + @DisplayName("단건 조회 성공") + void findOne() { + // Given + Long orderId = 1L; + Orders orders = mockOrder(orderId, DeliveryStatus.READY); + when(ordersRepository.findOrderById(orderId)).thenReturn(Optional.of(orders)); + + // When + OrdersResponse ordersResponse = ordersService.findOne(orderId); + + // Then + assertThat(ordersResponse.id()).isEqualTo(orderId); + assertThat(ordersResponse.totalPrice()).isEqualTo(orders.getTotalPrice()); + assertThat(ordersResponse.status()).isEqualTo(DeliveryStatus.READY); // READY를 기대 + assertThat(ordersResponse.createAt()).isEqualTo(orders.getCreatedAt()); + assertThat(ordersResponse.modifiedAt()).isEqualTo(orders.getModifiedAt()); + + ProductOrders firstProductOrder = orders.getProductOrdersList().get(0); + assertThat(firstProductOrder.getProduct().getName()).isEqualTo("A"); + assertThat(firstProductOrder.getProduct().getImgUrl()).isEqualTo("http://example.com/productA.jpg"); + + verify(ordersRepository).findOrderById(orderId); + } + + @Test + @DisplayName("단건 조회 NOT FOUND") + void not_found() { + // Given + Long orderId = 1L; + when(ordersRepository.findOrderById(orderId)).thenReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> ordersService.findOne(orderId)) + .isInstanceOf(OrdersException.class) + .hasMessage("해당 리소스를 찾을 수 없습니다"); + + } + + @Test + @DisplayName("현재 진행중인 주문 목록 조회 성공") + void current() { + // Given + String username = "testUser"; + Member member = mock(Member.class); + when(member.getId()).thenReturn(1L); + + // orders 목록 mock + List ordersList = List.of( + mockOrder(1L,DeliveryStatus.READY), + mockOrder(2L,DeliveryStatus.READY) + ); + + when(ordersRepository.findByMemberIdAndDeliveryStatus( + member.getId(), + DeliveryStatus.READY + )).thenReturn(ordersList); + + // When + List result = ordersService.current(member.getId()); + + // Then + assertThat(result).hasSize(2); + + // 첫 번째 주문 검증 + // + converter 에 정렬 추가되면서 해당 부분도 수정시간순으로 정렬 + OrdersResponse firstOrder = result.get(0); + assertThat(firstOrder.id()).isEqualTo(2L); + assertThat(firstOrder.totalPrice()).isEqualTo(1000); + assertThat(firstOrder.products()).hasSize(1); + + // 메서드 호출 검증 + verify(ordersRepository).findByMemberIdAndDeliveryStatus( + member.getId(), + DeliveryStatus.READY + ); + } + + @Test + @DisplayName("주문 총 가격 계산 성공") + void calculateTotalPrice() { + // Given + Product product1 = mock(Product.class); + lenient().when(product1.getPrice()).thenReturn(1000); + + Product product2 = mock(Product.class); + lenient().when(product2.getPrice()).thenReturn(2000); + + ProductOrders productOrder1 = mock(ProductOrders.class); + lenient().when(productOrder1.getProduct()).thenReturn(product1); + lenient().when(productOrder1.getQuantity()).thenReturn(2); + lenient().when(productOrder1.getTotalPrice()).thenReturn(1000 * 2); + + ProductOrders productOrder2 = mock(ProductOrders.class); + lenient().when(productOrder2.getProduct()).thenReturn(product2); + lenient().when(productOrder2.getQuantity()).thenReturn(3); + lenient().when(productOrder2.getTotalPrice()).thenReturn(2000 * 3); + + Member member = mock(Member.class); + Address address = mock(Address.class); + + // When + Orders orders = Orders.create() + .member(member) + .productOrdersList(List.of(productOrder1, productOrder2)) + .address(address) + .build(); + + // Then + assertThat(orders.getTotalPrice()).isEqualTo(2000 + 6000); + } + + @Test + @DisplayName("주문 시 수량 만큼 재고 감소") + void reduceQuantity() { + // Given + Product product1 = mock(Product.class); + lenient().when(product1.getPrice()).thenReturn(1000); + lenient().when(product1.getQuantity()).thenReturn(100); + + ProductOrders productOrders1 = ProductOrders.create() + .product(product1) + .quantity(10) + .price(product1.getPrice()) + .build(); + + Member member = mock(Member.class); + Address address = mock(Address.class); + + // When + Orders.create() + .member(member) + .productOrdersList(List.of(productOrders1)) + .address(address) + .build(); + + // Then + verify(product1).removeQuantity(10); + } + + @Test + @DisplayName("재고 부족 시 에러 발생") + void notEnoughQuantity() { + + // Given + Product product1 = mock(Product.class); + lenient().when(product1.getPrice()).thenReturn(1000); + lenient().when(product1.getQuantity()).thenReturn(100); + + doThrow(new ProductException(ProductErrorCode.INSUFFICIENT_QUANTITY)) + .when(product1).removeQuantity(101); + + Member member = mock(Member.class); + Address address = mock(Address.class); + + // When & Then + assertThatThrownBy(() -> { + ProductOrders productOrders1 = ProductOrders.create() + .product(product1) + .quantity(101) + .price(product1.getPrice()) + .build(); + + Orders order = Orders.create() + .member(member) + .address(address) + .productOrdersList(List.of(productOrders1)) + .build(); + }).isInstanceOf(ProductException.class) + .hasMessage(ProductErrorCode.INSUFFICIENT_QUANTITY.getMessage()); + } + @Test + @DisplayName("모든 주문 목록 조회 성공") + void history() { + // Given + String username = "testUser"; + Member member = mock(Member.class); + when(member.getId()).thenReturn(1L); + + // orders 목록 mock + Orders o1 = mockOrder(1L, DeliveryStatus.READY); + Orders o2 = mockOrder(2L, DeliveryStatus.SHIPPED); + List ordersList = List.of( + o1, + o2 + ); + + log.info("o1={}",o1.getModifiedAt()); + log.info("o2={}",o2.getModifiedAt()); + + when(ordersRepository.findAllByMemberIdAndDeliveryStatusOrderByModifiedAt( + member.getId(), + List.of( + DeliveryStatus.READY, + DeliveryStatus.SHIPPED) + )).thenReturn(ordersList); + + // When + List result = ordersService.history(member.getId()); + + // Then + assertThat(result).hasSize(2); + + OrdersResponse firstOrder = result.get(0); + // 수정시간이 최신인 주문이 먼저 조회되어야함 + assertThat(firstOrder.id()).isEqualTo(o2.getId()); + assertThat(firstOrder.products()).hasSize(1); + + // 메서드 호출 검증 + verify(ordersRepository).findAllByMemberIdAndDeliveryStatusOrderByModifiedAt( + member.getId(), + List.of( + DeliveryStatus.READY, + DeliveryStatus.SHIPPED) + ); + } + + //todo 이미 취소 상태일때 취소불가, 배송중일때 취소 불가, 수량 정상 복구 + + @Test + @DisplayName("주문 정상 취소") + void order_cancel_success() { + Orders orders = mockOrder(1L, DeliveryStatus.READY); + + when(ordersRepository.findOrderById(1L)).thenReturn(Optional.of(orders)); + + ordersService.cancelById(1L); + + verify(orders).changeStatus(DeliveryStatus.CANCEL); + verify(orders.getProductOrdersList().get(0)).restore(1); + verify(ordersRepository).save(orders); + } + + @Test + @DisplayName("이미 주문 취소 상태이면 취소 불가") + void fail_if_status_cancel() { + + Orders orders = mockOrder(1L, DeliveryStatus.CANCEL); + when(ordersRepository.findOrderById(1L)).thenReturn(Optional.of(orders)); + + assertThatThrownBy(() -> ordersService.cancelById(1L)) + .isInstanceOf(OrdersException.class) + .hasMessage("이미 취소된 상품입니다."); + + } + + @Test + @DisplayName("배송중일때 취소 불가") + void fail_if_status_shipped() { + Orders orders = mockOrder(1L, DeliveryStatus.SHIPPED); + + when(ordersRepository.findOrderById(1L)).thenReturn(Optional.of(orders)); + + assertThatThrownBy(() -> ordersService.cancelById(1L)) + .isInstanceOf(OrdersException.class) + .hasMessage("이미 배송중입니다."); + + } +} diff --git a/backend/src/test/java/com/example/backend/domain/product/controller/ProductControllerTest.java b/backend/src/test/java/com/example/backend/domain/product/controller/ProductControllerTest.java new file mode 100644 index 00000000..ac54b6df --- /dev/null +++ b/backend/src/test/java/com/example/backend/domain/product/controller/ProductControllerTest.java @@ -0,0 +1,508 @@ +package com.example.backend.domain.product.controller; + +import com.example.backend.domain.product.converter.ProductConverter; +import com.example.backend.domain.product.dto.ProductForm; +import com.example.backend.domain.product.dto.ProductResponse; +import com.example.backend.domain.product.entity.Product; +import com.example.backend.domain.product.exception.ProductErrorCode; +import com.example.backend.domain.product.exception.ProductException; +import com.example.backend.domain.product.service.ProductService; +import com.example.backend.global.config.CorsConfig; +import com.example.backend.global.config.TestSecurityConfig; +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.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.*; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithAnonymousUser; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +import java.util.ArrayList; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** + * ProductControllerTest + * ProductController 테스트 클래스 + * + * @author 100minha + */ + +@WebMvcTest(ProductController.class) +@Import({TestSecurityConfig.class, CorsConfig.class}) +@WithMockUser(roles = "ADMIN") +public class ProductControllerTest { + + @MockitoBean + private ProductService productService; + + @Autowired + private MockMvc mockMvc; + + @Test + @DisplayName("상품 등록 성공 테스트") + void createSuccessTest() throws Exception { + // given + doNothing().when(productService).create(any(ProductForm.class)); + + // when + ResultActions resultActions = mockMvc.perform(post("/api/v1/products") + .content(""" + { + "name": "Test Product Name", + "content": "Test Product content", + "price": 1000, + "imgUrl": "Test Product Image URL", + "quantity": 10 + } + """) + .contentType(MediaType.APPLICATION_JSON) + ); + + // then + resultActions + .andExpect(handler().handlerType(ProductController.class)) + .andExpect(handler().methodName("create")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.message").value("상품이 정상적으로 등록되었습니다.")); + } + + @Test + @DisplayName("중복된 상품 이름으로 인한 등록 실패 테스트") + void createFailWhenNameIsExistsTest() throws Exception { + // given + doThrow(new ProductException(ProductErrorCode.EXISTS_NAME)).when(productService).create(any(ProductForm.class)); + + // when + ResultActions resultActions = mockMvc.perform(post("/api/v1/products") + .content(""" + { + "name": "Test Product Name", + "content": "Test Product content", + "price": 1000, + "imgUrl": "Test Product Image URL", + "quantity": 10 + } + """) + .contentType(MediaType.APPLICATION_JSON) + ); + + // then + resultActions + .andExpect(handler().handlerType(ProductController.class)) + .andExpect(handler().methodName("create")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("중복된 상품 이름입니다.")); + } + + @Test + @DisplayName("상품 이름 글자수 초과로 인한 등록 실패 테스트") + void createFailWhenNameInvalidTest() throws Exception { + // given + doNothing().when(productService).create(any(ProductForm.class)); + + // when + ResultActions resultActions = mockMvc.perform(post("/api/v1/products") + .content(""" + { + "name": "Test Product Name Test Product Name Test Product Name Test Product Name Test Product Name Test Product Name Test Product Name", + "content": "Test Product content", + "price": 1000, + "imgUrl": "Test Product Image URL", + "quantity": 10 + } + """) + .contentType(MediaType.APPLICATION_JSON) + ); + + // then + resultActions + .andExpect(handler().handlerType(ProductController.class)) + .andExpect(handler().methodName("create")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("400-1")) + .andExpect(jsonPath("$.errorDetails[0].field").value("name")) + .andExpect(jsonPath("$.errorDetails[0].reason").value("상품 이름은 2자 이상 50자 이하여야 합니다.")); + } + + @Test + @DisplayName("상품 가격 최솟값 미달로 인한 등록 실패 테스트") + void createFailWhenPriceIsBelowMinTest() throws Exception { + // given + doNothing().when(productService).create(any(ProductForm.class)); + + // when + ResultActions resultActions = mockMvc.perform(post("/api/v1/products") + .content(""" + { + "name": "Test Product Name", + "content": "Test Product content", + "price": 10, + "imgUrl": "Test Product Image URL", + "quantity": 10 + } + """) + .contentType(MediaType.APPLICATION_JSON) + ); + + // then + resultActions + .andExpect(handler().handlerType(ProductController.class)) + .andExpect(handler().methodName("create")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("400-1")) + .andExpect(jsonPath("$.errorDetails[0].field").value("price")) + .andExpect(jsonPath("$.errorDetails[0].reason").value("상품 가격은 100원 이상이어야 합니다.")); + } + + @Test + @DisplayName("상품 가격 최댓값 초과로 인한 등록 실패 테스트") + void createFailWhenPriceIsAboveMaxTest() throws Exception { + // given + doNothing().when(productService).create(any(ProductForm.class)); + + // when + ResultActions resultActions = mockMvc.perform(post("/api/v1/products") + .content(""" + { + "name": "Test Product Name", + "content": "Test Product content", + "price": 999999999, + "imgUrl": "Test Product Image URL", + "quantity": 10 + } + """) + .contentType(MediaType.APPLICATION_JSON) + ); + + // then + resultActions + .andExpect(handler().handlerType(ProductController.class)) + .andExpect(handler().methodName("create")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("400-1")) + .andExpect(jsonPath("$.errorDetails[0].field").value("price")) + .andExpect(jsonPath("$.errorDetails[0].reason").value("상품 가격은 9,999,999원 이하여야 합니다.")); + } + + @Test + @DisplayName("상품 가격 Integer 최댓값 초과로 인한 등록 실패 테스트") + void createFailWhenPriceIsAboveMaxIntegerTest() throws Exception { + // given + doNothing().when(productService).create(any(ProductForm.class)); + + // when + ResultActions resultActions = mockMvc.perform(post("/api/v1/products") + .content(""" + { + "name": "Test Product Name", + "content": "Test Product content", + "price": 99999999999, + "imgUrl": "Test Product Image URL", + "quantity": 10 + } + """) + .contentType(MediaType.APPLICATION_JSON) + ); + + // then + resultActions + .andExpect(handler().handlerType(ProductController.class)) + .andExpect(handler().methodName("create")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("400")); + } + + @Test + @DisplayName("상품 재고 음수 설정으로 인한 등록 실패 테스트") + void createFailWhenQuantityIsBelowZeroTest() throws Exception { + // given + doNothing().when(productService).create(any(ProductForm.class)); + + // when + ResultActions resultActions = mockMvc.perform(post("/api/v1/products") + .content(""" + { + "name": "Test Product Name", + "content": "Test Product content", + "price": 1000, + "imgUrl": "Test Product Image URL", + "quantity": -1 + } + """) + .contentType(MediaType.APPLICATION_JSON) + ); + + // then + resultActions + .andExpect(handler().handlerType(ProductController.class)) + .andExpect(handler().methodName("create")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("400-1")) + .andExpect(jsonPath("$.errorDetails[0].field").value("quantity")) + .andExpect(jsonPath("$.errorDetails[0].reason").value("상품 수량은 0 이상이어야 합니다.")); + } + + @Test + @DisplayName("상품 조회 성공 테스트") + @WithAnonymousUser + void findSuccessTest() throws Exception { + //given + Long id = 1L; + Product product = Product.builder() + .name("Test Product Name") + .build(); + ProductResponse productResponse = ProductConverter.from(product); + when(productService.findProductResponseById(id)).thenReturn(productResponse); + + //when + ResultActions resultActions = mockMvc.perform(get("/api/v1/products/1" ) + .contentType(MediaType.APPLICATION_JSON) + ); + + //then + resultActions + .andExpect(handler().handlerType(ProductController.class)) + .andExpect(handler().methodName("findById")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.name").value("Test Product Name")); + } + + @Test + @DisplayName("상품 조회 실패 테스트") + @WithAnonymousUser + void findFailTest() throws Exception { + //given + doThrow(new ProductException(ProductErrorCode.NOT_FOUND)).when(productService).findProductResponseById(anyLong()); + + //when + ResultActions resultActions = mockMvc.perform(get("/api/v1/products/1" ) + .contentType(MediaType.APPLICATION_JSON) + ); + + //then + resultActions + .andExpect(handler().handlerType(ProductController.class)) + .andExpect(handler().methodName("findById")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.message").value("상품을 찾을 수 없습니다.")); + } + + @Test + @DisplayName("상품 다건 조회 테스트") + @WithAnonymousUser + void findAllPagedTest() throws Exception { + // given + List productResponseList = new ArrayList<>(); + int page = 1; + + for (int i = 1; i <= 5; i++) { + productResponseList.add(ProductConverter.from(Product.builder() + .name("Test Name_" + i) + .build())); + } + + Sort sortByNameAsc = Sort.by(Sort.Order.asc("name")); + Pageable pageable = PageRequest.of(page, 10, sortByNameAsc); + Page mockPage = new PageImpl<>(productResponseList, pageable, 15); + + when(productService.findAllPaged(page)).thenReturn(mockPage); + + //when + ResultActions resultActions = mockMvc.perform(get("/api/v1/products" ) + .contentType(MediaType.APPLICATION_JSON) + .param("page", page+"") + ); + + //then + verify(productService, times(1)).findAllPaged(page); + + resultActions + .andExpect(handler().handlerType(ProductController.class)) + .andExpect(handler().methodName("findAllPaged")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content[2].name").value("Test Name_3")); + } + + @Test + @DisplayName("상품 다건 조회 빈 페이지 반환 시 404반환 테스트") + @WithAnonymousUser + void findAllPagedButIsEmptyTest() throws Exception { + // given + int inValidPage = 999; // 빈 페이지 + + Sort sortByNameAsc = Sort.by(Sort.Order.asc("name")); + Pageable pageable = PageRequest.of(inValidPage, 10, sortByNameAsc); + + doThrow(new ProductException(ProductErrorCode.NOT_FOUND)).when(productService).findAllPaged(inValidPage); + + //when + ResultActions resultActions = mockMvc.perform(get("/api/v1/products" ) + .contentType(MediaType.APPLICATION_JSON) + .param("page", inValidPage+"") + ); + + //then + verify(productService, times(1)).findAllPaged(inValidPage); + + resultActions + .andExpect(handler().handlerType(ProductController.class)) + .andExpect(handler().methodName("findAllPaged")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.message").value("상품을 찾을 수 없습니다.")); + } + + @Test + @DisplayName("상품 수정 성공 테스트") + void modifySuccessTest() throws Exception { + // given + doNothing().when(productService).modify(anyLong(), any(ProductForm.class)); + + // when + ResultActions resultActions = mockMvc.perform(patch("/api/v1/products/1") + .content(""" + { + "name": "Test Product Name", + "content": "Test Product content", + "price": 1000, + "imgUrl": "Test Product Image URL", + "quantity": 10 + } + """) + .contentType(MediaType.APPLICATION_JSON) + ); + + // then + resultActions + .andExpect(handler().handlerType(ProductController.class)) + .andExpect(handler().methodName("modify")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("상품이 정상적으로 수정되었습니다.")); + } + + @Test + @DisplayName("상품 수정 실패(없는 상품 수정 시도) 테스트") + void modifyFailWhenProductNotFoundTest() throws Exception { + // given + Long notExistId = 999L; + doThrow(new ProductException(ProductErrorCode.NOT_FOUND)).when(productService) + .modify(eq(notExistId), any(ProductForm.class)); + + // when + ResultActions resultActions = mockMvc.perform(patch("/api/v1/products/" + notExistId) + .content(""" + { + "name": "Test Product Name", + "content": "Test Product content", + "price": 1000, + "imgUrl": "Test Product Image URL", + "quantity": 10 + } + """) + .contentType(MediaType.APPLICATION_JSON) + ); + + // then + resultActions + .andExpect(handler().handlerType(ProductController.class)) + .andExpect(handler().methodName("modify")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.message").value("상품을 찾을 수 없습니다.")); + } + + @Test + @DisplayName("상품 수정 실패(상품 이름 중복) 테스트") + void modifyFailWhenNameAlreadyExistsTest() throws Exception { + // given + doThrow(new ProductException(ProductErrorCode.EXISTS_NAME)).when(productService) + .modify(eq(1L), any(ProductForm.class)); + + // when + ResultActions resultActions = mockMvc.perform(patch("/api/v1/products/1") + .content(""" + { + "name": "Test Product Name", + "content": "Test Product content", + "price": 1000, + "imgUrl": "Test Product Image URL", + "quantity": 10 + } + """) + .contentType(MediaType.APPLICATION_JSON) + ); + + // then + resultActions + .andExpect(handler().handlerType(ProductController.class)) + .andExpect(handler().methodName("modify")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("중복된 상품 이름입니다.")); + } + + @Test + @DisplayName("상품 삭제 성공 테스트") + void deleteSuccessTest() throws Exception { + + doNothing().when(productService).delete(1L); + + //when + ResultActions resultActions = mockMvc.perform(delete("/api/v1/products/1" ) + .contentType(MediaType.APPLICATION_JSON) + ); + + //then + resultActions + .andExpect(handler().handlerType(ProductController.class)) + .andExpect(handler().methodName("delete")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("상품이 정상적으로 삭제되었습니다.")); + } + + @Test + @DisplayName("상품 삭제 실패(해당 상품 존재하지 않음) 테스트") + void deleteFailWhenProductNotExistsTest() throws Exception { + + doThrow(new ProductException(ProductErrorCode.NOT_FOUND)).when(productService).delete(1L); + + //when + ResultActions resultActions = mockMvc.perform(delete("/api/v1/products/1" ) + .contentType(MediaType.APPLICATION_JSON) + ); + + //then + resultActions + .andExpect(handler().handlerType(ProductController.class)) + .andExpect(handler().methodName("delete")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.message").value("상품을 찾을 수 없습니다.")); + } + + @Test + @DisplayName("상품 삭제 실패(해당 상품 주문 내역 존재) 테스트") + void deleteFailWhenProductExistsOrderHistoryTest() throws Exception { + + doThrow(new ProductException(ProductErrorCode.EXISTS_ORDER_HISTORY)).when(productService).delete(1L); + + //when + ResultActions resultActions = mockMvc.perform(delete("/api/v1/products/1" ) + .contentType(MediaType.APPLICATION_JSON) + ); + + //then + resultActions + .andExpect(handler().handlerType(ProductController.class)) + .andExpect(handler().methodName("delete")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("주문 내역이 존재하는 상품입니다.")); + } + +} diff --git a/backend/src/test/java/com/example/backend/domain/product/repository/ProductRepositoryTest.java b/backend/src/test/java/com/example/backend/domain/product/repository/ProductRepositoryTest.java new file mode 100644 index 00000000..73ab96c9 --- /dev/null +++ b/backend/src/test/java/com/example/backend/domain/product/repository/ProductRepositoryTest.java @@ -0,0 +1,220 @@ +package com.example.backend.domain.product.repository; + +import com.example.backend.domain.orders.dto.OrdersForm; +import com.example.backend.domain.product.dto.ProductResponse; +import com.example.backend.domain.product.entity.Product; +import com.example.backend.domain.productOrders.entity.ProductOrders; +import com.example.backend.domain.productOrders.repository.ProductOrdersRepository; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.junit.jupiter.api.BeforeEach; +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.orm.jpa.DataJpaTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * ProductReposiotryTest + * ProductRepository 단위 테스트 진행 코드 + * + * @author 100minha + */ +@DataJpaTest +@ActiveProfiles("test") +@Transactional +public class ProductRepositoryTest { + @Autowired + private ProductRepository productRepository; + @Autowired + private ProductOrdersRepository productOrdersRepository; + + @PersistenceContext + private EntityManager em; + + private final String name1 = "Test Product Name"; + private final String content1 = "Test Product Description"; + private final int price1 = 1000; + private final String imgUrl1 = "Test Product Image"; + private final int quantity1 = 10; + + private Long productId; + + /** + * id 1부터 시작하도록 초기화 후 상품 생성 + */ + @BeforeEach + void setUp() { + em.createNativeQuery("ALTER TABLE product ALTER COLUMN id RESTART WITH 1").executeUpdate(); + Product product = Product.builder() + .name(name1) + .content(content1) + .price(price1) + .imgUrl(imgUrl1) + .quantity(quantity1) + .build(); + + productRepository.save(product); + } + + @Test + @DisplayName("상품 저장 테스트") + void saveTest() { + // given + // when + Optional optionalProduct = productRepository.findById(1L); + + // then + assertThat(optionalProduct.isPresent()).isTrue(); + assertThat(optionalProduct.get()).isInstanceOf(Product.class); + } + + @Test + @DisplayName("상품 이름 유일성 검사(중복) 테스트") + void existsByNameTest() { + //given + //when + Boolean isExists = productRepository.existsByName(name1); + + //then + assertThat(isExists).isTrue(); + } + + @Test + @DisplayName("상품 이름 유일성 검사(유일) 테스트") + void notExistsByNameTest() { + //given + String name = "Unique Product Name"; + + //when + Boolean isExists = productRepository.existsByName(name); + + //then + assertThat(isExists).isFalse(); + } + + @Test + @DisplayName("상품 특정 id 제외 이름 유일성 검사 테스트") + void existsByNameAndIdNotTest() { + //given + String name2 = "Test Product Name2"; + String uniqueName = "Unique Product Name"; + + Product product2 = Product.builder() + .name(name2) + .build(); + productRepository.save(product2); + Long id = 2L; + + //when + Boolean isExists1 = productRepository.existsByNameAndIdNot(name2, id); // 수정 목표 상품 id의 name + Boolean isExists2 = productRepository.existsByNameAndIdNot(name1, id); // 다른 상품의 name + Boolean isExists3 = productRepository.existsByNameAndIdNot(uniqueName, id); // 수정 했을 때 아예 다른 name + + //then + assertThat(isExists1).isFalse(); + assertThat(isExists2).isTrue(); + assertThat(isExists3).isFalse(); + } + + @Test + @DisplayName("상품 단건 조회(Entity) 테스트") + void findByIdTest() { + // given + // when + Optional optionalProduct = productRepository.findById(1L); + + // then + assertThat(optionalProduct.isPresent()).isTrue(); + Product product = optionalProduct.get(); + assertThat(product.getName()).isEqualTo(this.name1); + assertThat(product.getContent()).isEqualTo(this.content1); + assertThat(product.getPrice()).isEqualTo(this.price1); + assertThat(product.getImgUrl()).isEqualTo(this.imgUrl1); + assertThat(product.getQuantity()).isEqualTo(this.quantity1); + } + + @Test + @DisplayName("상품 단건 조회(DTO) 테스트") + void findProductResponseByIdTest() { + // given + // when + Optional optionalProductResponse = productRepository.findProductResponseById(1L); + + // then + assertThat(optionalProductResponse.isPresent()).isTrue(); + ProductResponse productResponse = optionalProductResponse.get(); + assertThat(productResponse.name()).isEqualTo(this.name1); + assertThat(productResponse.content()).isEqualTo(this.content1); + assertThat(productResponse.price()).isEqualTo(this.price1); + assertThat(productResponse.imgUrl()).isEqualTo(this.imgUrl1); + } + + @Test + @DisplayName("상품 다건 조회 테스트") + void findAllPagedTest() { + // given + PageRequest pageRequest1 = PageRequest.of(0, 10); // 1페이지 + PageRequest pageRequest2 = PageRequest.of(1, 10); // 2페이지 + + for (int i = 1; i <= 15; i++) { + productRepository.save(Product.builder() // 15 + setUp()에서 +1 + .build()); + } + + // when + Page productResponsePage1 = productRepository.findAllPaged(pageRequest1); + Page productResponsePage2 = productRepository.findAllPaged(pageRequest2); + + // then + assertThat(productResponsePage1).isNotNull(); + assertThat(productResponsePage1.getTotalPages()).isEqualTo(2); + assertThat(productResponsePage1.getTotalElements()).isEqualTo(16); + + assertThat(productResponsePage1.getNumberOfElements()).isEqualTo(10); + assertThat(productResponsePage2.getNumberOfElements()).isEqualTo(6); + + } + + @Test + @DisplayName("상품 삭제 성공 테스트") + void deleteTest() { + //given + Long id = 1L; + Product product = productRepository.findById(id).get(); + + //when + productRepository.delete(product); + + //then + assertThat(productRepository.findById(id).isPresent()).isFalse(); + } + + @Test + @DisplayName("상품 주문 내역 존재 여부 검증 테스트") + void existsProductOrdersByProductIdTest() { + //given + Product product1 = productRepository.findById(1L).get(); + + ProductOrders productOrders = ProductOrders.create() + .product(product1) + .build(); + + productOrdersRepository.save(productOrders); + + //when + boolean isExists1 = productOrdersRepository.existsByProductId(1L); + boolean isExists2 = productOrdersRepository.existsByProductId(2L); + + //then + assertThat(isExists1).isTrue(); + assertThat(isExists2).isFalse(); + } +} diff --git a/backend/src/test/java/com/example/backend/domain/product/service/ProductServiceTest.java b/backend/src/test/java/com/example/backend/domain/product/service/ProductServiceTest.java new file mode 100644 index 00000000..cd92a7a6 --- /dev/null +++ b/backend/src/test/java/com/example/backend/domain/product/service/ProductServiceTest.java @@ -0,0 +1,331 @@ +package com.example.backend.domain.product.service; + +import com.example.backend.domain.product.converter.ProductConverter; +import com.example.backend.domain.product.dto.ProductForm; +import com.example.backend.domain.product.dto.ProductResponse; +import com.example.backend.domain.product.entity.Product; +import com.example.backend.domain.product.exception.ProductException; +import com.example.backend.domain.product.repository.ProductRepository; +import com.example.backend.domain.productOrders.repository.ProductOrdersRepository; +import org.junit.jupiter.api.DisplayName; +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.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.*; +import org.springframework.http.HttpStatus; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; + +/** + * ProductServiceTest + * ProductService 테스트 클래스 + * + * @author 100minha + */ +@ExtendWith(MockitoExtension.class) +class ProductServiceTest { + + @Mock + private ProductRepository productRepository; + @Mock + private ProductOrdersRepository productOrdersRepository; + + @InjectMocks + private ProductService productService; + + private final String name1 = "Test Product Name"; + private final String content1 = "Test Product Description"; + private final int price1 = 1000; + private final String imgUrl1 = "Test Product Image"; + private final int quantity1 = 10; + + ProductForm productForm1 = ProductForm.builder() + .name(name1) + .content(content1) + .price(price1) + .imgUrl(imgUrl1) + .quantity(quantity1) + .build(); + Product product1 = ProductConverter.from(productForm1); + + @Test + @DisplayName("상품 등록 테스트") + void createTest() { + // given + ArgumentCaptor productCaptor = ArgumentCaptor.forClass(Product.class); + when(productRepository.existsByName(productForm1.name())).thenReturn(false); + + // when + productService.create(productForm1); + + // then + verify(productRepository, times(1)).save(productCaptor.capture()); + Product savedProduct = productCaptor.getValue(); + + assertThat(savedProduct.getName()).isEqualTo(name1); + assertThat(savedProduct.getContent()).isEqualTo(content1); + assertThat(savedProduct.getPrice()).isEqualTo(price1); + assertThat(savedProduct.getImgUrl()).isEqualTo(imgUrl1); + assertThat(savedProduct.getQuantity()).isEqualTo(quantity1); + } + + @Test + @DisplayName("중복 이름 상품 등록 테스트") + void alreadyExistsCreateTest() { + // given + when(productRepository.existsByName(productForm1.name())).thenReturn(true); + + // when + ProductException exception = assertThrows( + ProductException.class, + () -> productService.create(productForm1) + ); + + // then + verify(productRepository, times(1)).existsByName(productForm1.name()); + assertThat(exception.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(exception.getCode()).isEqualTo("400-2"); + assertThat(exception.getMessage()).isEqualTo("중복된 상품 이름입니다."); + } + + @Test + @DisplayName("상품 단건 조회(Entity) 성공 테스트") + void findByIdSuccessTest() { + // given + Long id = 1L; + when(productRepository.findById(id)).thenReturn(Optional.of(product1)); + + // when + Product product = productService.findById(id); + + // then + assertThat(product.getName()).isEqualTo(this.name1); + assertThat(product.getContent()).isEqualTo(this.content1); + assertThat(product.getPrice()).isEqualTo(this.price1); + assertThat(product.getImgUrl()).isEqualTo(this.imgUrl1); + assertThat(product.getQuantity()).isEqualTo(this.quantity1); + } + + @Test + @DisplayName("상품 단건 조회(Entity) 실패 테스트") + void findByIdFailTest() { + // given + Long invalidId = 999L; // 존재하지 않는 상품 ID + when(productRepository.findById(invalidId)).thenReturn(Optional.empty()); + + // when + ProductException exception = assertThrows( + ProductException.class, + () -> productService.findById(invalidId) + ); + + // then + assertThat(exception.getCode()).isEqualTo("404"); + } + + @Test + @DisplayName("상품 단건 조회(DTO) 테스트") + void findProductResponseByIdSuccessTest() { + // given + Long id = 1L; + when(productRepository.findProductResponseById(id)).thenReturn(Optional.of(ProductConverter.from(product1))); + + // when + ProductResponse productResponse = productService.findProductResponseById(1L); + + // then + assertThat(productResponse.name()).isEqualTo(this.name1); + assertThat(productResponse.content()).isEqualTo(this.content1); + assertThat(productResponse.price()).isEqualTo(this.price1); + assertThat(productResponse.imgUrl()).isEqualTo(this.imgUrl1); + } + + @Test + @DisplayName("상품 단건 조회(DTO) 실패 테스트") + void findProductResponseByIdFailTest() { + // given + Long invalidId = 999L; // 존재하지 않는 상품 ID + when(productRepository.findProductResponseById(invalidId)).thenReturn(Optional.empty()); + + // when + ProductException exception = assertThrows( + ProductException.class, + () -> productService.findProductResponseById(invalidId) + ); + + // then + assertThat(exception.getCode()).isEqualTo("404"); + } + + @Test + @DisplayName("상품 다건 조회 테스트") + void findAllPagedTest() { + // given + Sort sortByNameAsc = Sort.by(Sort.Order.asc("name")); + List productResponseList = new ArrayList<>(); + + for (int i = 1; i <= 5; i++) { + productResponseList.add(ProductConverter.from(Product.builder() + .name("Test Name_" + i) + .build())); + } + + Pageable pageable = PageRequest.of(0, 10, sortByNameAsc); + Page mockPage = new PageImpl<>(productResponseList, pageable, 5); + when(productRepository.findAllPaged(any())).thenReturn(mockPage); + + // when + Page productResponsePage = productService.findAllPaged(0); + + //then + verify(productRepository, times(1)).findAllPaged(pageable); + + assertThat(productResponsePage.getTotalPages()).isEqualTo(1); + assertThat(productResponsePage.getNumberOfElements()).isEqualTo(5); + assertThat(productResponsePage.getContent().get(3).name()).isEqualTo("Test Name_4"); + } + + @Test + @DisplayName("상품 다건 조회 빈 페이지 반환 시 404반환 테스트") + void findAllPagedButIsEmptyTest() { + // given + Sort sortByNameAsc = Sort.by(Sort.Order.asc("name")); + Pageable inValidPageable = PageRequest.of(999, 10, sortByNameAsc); //빈 페이지 요청 + when(productRepository.findAllPaged(inValidPageable)).thenReturn(Page.empty()); + + // when + ProductException exception = assertThrows( + ProductException.class, + () -> productService.findAllPaged(999) + ); + + //then + verify(productRepository, times(1)).findAllPaged(inValidPageable); + + assertThat(exception.getCode()).isEqualTo("404"); + } + + @Test + @DisplayName("상품 수정 테스트(더티체킹)") + void modifyTest() { + // given + when(productRepository.findById(1L)).thenReturn(Optional.of(product1)); + ProductForm updatedproductForm = ProductForm.builder() + .name("Updated Name") + .content("Updated Content") + .price(12345) + .imgUrl("Updated imgUrl") + .quantity(123) + .build(); + when(productRepository.existsByNameAndIdNot(updatedproductForm.name(), 1L)).thenReturn(false); + + // when + productService.modify(1L, updatedproductForm); + + // then + assertThat(product1.getName()).isEqualTo(updatedproductForm.name()); + assertThat(product1.getContent()).isEqualTo(updatedproductForm.content()); + assertThat(product1.getPrice()).isEqualTo(updatedproductForm.price()); + assertThat(product1.getImgUrl()).isEqualTo(updatedproductForm.imgUrl()); + assertThat(product1.getQuantity()).isEqualTo(updatedproductForm.quantity()); + } + + @Test + @DisplayName("중복 이름 상품 수정 테스트") + void alreadyExistsModifyTest() { + // given + ProductForm updatedproductForm = ProductForm.builder() + .name("Updated Name") + .build(); + when(productRepository.existsByNameAndIdNot(updatedproductForm.name(), 1L)).thenReturn(true); + + + // when + ProductException exception = assertThrows( + ProductException.class, + () -> productService.modify(1L, updatedproductForm) + ); + + // then + verify(productRepository, times(1)).existsByNameAndIdNot(updatedproductForm.name(), 1L); + assertThat(exception.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(exception.getCode()).isEqualTo("400-2"); + assertThat(exception.getMessage()).isEqualTo("중복된 상품 이름입니다."); + } + + @Test + @DisplayName("상품 삭제 성공 테스트") + void deleteSuccessTest() { + //given + Long id = 1L; + ArgumentCaptor productCaptor = ArgumentCaptor.forClass(Product.class); + when(productOrdersRepository.existsByProductId(id)).thenReturn(false); + when(productRepository.findById(id)).thenReturn(Optional.of(product1)); + + //when + productService.delete(id); + + // then + verify(productOrdersRepository, times(1)).existsByProductId(id); + verify(productRepository, times(1)).delete(productCaptor.capture()); + Product deletedProduct = productCaptor.getValue(); + + assertThat(deletedProduct.getName()).isEqualTo(name1); + assertThat(deletedProduct.getContent()).isEqualTo(content1); + assertThat(deletedProduct.getPrice()).isEqualTo(price1); + assertThat(deletedProduct.getImgUrl()).isEqualTo(imgUrl1); + assertThat(deletedProduct.getQuantity()).isEqualTo(quantity1); + } + + @Test + @DisplayName("상품 삭제 실패(해당 상품 존재하지 않음) 테스트") + void deleteFailWhenProductNotExistsTest() { + //given + Long id = 1L; + when(productOrdersRepository.existsByProductId(id)).thenReturn(false); + when(productRepository.findById(id)).thenReturn(Optional.empty()); + + //when + ProductException exception = assertThrows(ProductException.class, + () -> productService.delete(id) + ); + + // then + verify(productOrdersRepository, times(1)).existsByProductId(id); + verify(productRepository, times(1)).findById(id); + + assertThat(exception.getStatus()).isEqualTo(HttpStatus.NOT_FOUND); + assertThat(exception.getCode()).isEqualTo("404"); + assertThat(exception.getMessage()).isEqualTo("상품을 찾을 수 없습니다."); + } + + @Test + @DisplayName("상품 삭제 실패(해당 상품 주문 내역 존재) 테스트") + void deleteFailWhenProductExistsOrderHistoryTest() { + //given + Long id = 1L; + when(productOrdersRepository.existsByProductId(id)).thenReturn(true); + + //when + ProductException exception = assertThrows(ProductException.class, + () -> productService.delete(id) + ); + + // then + verify(productOrdersRepository, times(1)).existsByProductId(id); + + assertThat(exception.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(exception.getCode()).isEqualTo("400-3"); + assertThat(exception.getMessage()).isEqualTo("주문 내역이 존재하는 상품입니다."); + } + +} \ No newline at end of file diff --git a/backend/src/test/java/com/example/backend/global/auth/controller/AuthControllerTest.java b/backend/src/test/java/com/example/backend/global/auth/controller/AuthControllerTest.java new file mode 100644 index 00000000..3ee997ab --- /dev/null +++ b/backend/src/test/java/com/example/backend/global/auth/controller/AuthControllerTest.java @@ -0,0 +1,513 @@ +package com.example.backend.global.auth.controller; + +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +import com.example.backend.domain.common.VerifyType; +import com.example.backend.domain.member.exception.MemberErrorCode; +import com.example.backend.domain.member.exception.MemberException; +import com.example.backend.global.auth.dto.AuthForm; +import com.example.backend.global.auth.dto.AuthResponse; +import com.example.backend.global.auth.dto.EmailCertificationForm; +import com.example.backend.global.auth.dto.SendEmailCertificationCodeForm; +import com.example.backend.global.auth.exception.AuthErrorCode; +import com.example.backend.global.auth.exception.AuthException; +import com.example.backend.global.auth.service.AuthService; +import com.example.backend.global.auth.service.CookieService; +import com.example.backend.global.config.CorsConfig; +import com.example.backend.global.config.TestSecurityConfig; +import com.example.backend.global.exception.GlobalErrorCode; +import com.example.backend.global.exception.GlobalException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; + +@ExtendWith(SpringExtension.class) +@WebMvcTest(AuthController.class) +@Import({TestSecurityConfig.class, CorsConfig.class}) +@Slf4j +public class AuthControllerTest { + @MockitoBean + AuthService authService; + + @MockitoBean + CookieService cookieService; + + @Autowired + private MockMvc mockMvc; + + @Autowired + ObjectMapper objectMapper; + + @DisplayName("이메일 인증 성공 테스트") + @Test + void verify_success() throws Exception { + //given + EmailCertificationForm givenEmailCertificationForm = EmailCertificationForm.builder() + .username("testEmail@naver.com") + .certificationCode("testCode") + .verifyType(VerifyType.SIGNUP) + .build(); + + doNothing().when(authService).verify(givenEmailCertificationForm.username(), + givenEmailCertificationForm.certificationCode(), givenEmailCertificationForm.verifyType()); + + //when + ResultActions resultActions = mockMvc.perform(post("/api/v1/auth/verify") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(givenEmailCertificationForm))); + + //then + resultActions.andExpect(status().isOk()); + } + + @DisplayName("이메일 인증 정보 조회 실패 테스트") + @Test + void verify_certification_not_found_fail() throws Exception { + //given + EmailCertificationForm givenEmailCertificationForm = EmailCertificationForm.builder() + .username("testEmail@naver.com") + .certificationCode("testCode") + .verifyType(VerifyType.SIGNUP) + .build(); + + doThrow(new AuthException(AuthErrorCode.CERTIFICATION_CODE_NOT_FOUND)) + .when(authService).verify(givenEmailCertificationForm.username(), + givenEmailCertificationForm.certificationCode(), givenEmailCertificationForm.verifyType()); + + //when + ResultActions resultActions = mockMvc.perform(post("/api/v1/auth/verify") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(givenEmailCertificationForm))); + + //then + resultActions + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(AuthErrorCode.CERTIFICATION_CODE_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(AuthErrorCode.CERTIFICATION_CODE_NOT_FOUND.getMessage())); + } + + @DisplayName("이메일 인증 코드 일치하지 않을 때 실패 테스트") + @Test + void verify_certification_not_match_fail() throws Exception { + //given + EmailCertificationForm givenEmailCertificationForm = EmailCertificationForm.builder() + .username("testEmail@naver.com") + .certificationCode("testCode") + .verifyType(VerifyType.SIGNUP) + .build(); + + doThrow(new AuthException(AuthErrorCode.CERTIFICATION_CODE_NOT_MATCH)) + .when(authService).verify(givenEmailCertificationForm.username(), + givenEmailCertificationForm.certificationCode(), givenEmailCertificationForm.verifyType()); + + //when + ResultActions resultActions = mockMvc.perform(post("/api/v1/auth/verify") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(givenEmailCertificationForm))); + + //then + resultActions + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(AuthErrorCode.CERTIFICATION_CODE_NOT_MATCH.getCode())) + .andExpect(jsonPath("$.message").value(AuthErrorCode.CERTIFICATION_CODE_NOT_MATCH.getMessage())); + } + + @DisplayName("이메일 인증 타입 일치하지 않을 때 실패 테스트") + @Test + void verify_verify_type_not_match_fail() throws Exception { + //given + EmailCertificationForm givenEmailCertificationForm = EmailCertificationForm.builder() + .username("testEmail@naver.com") + .certificationCode("testCode") + .verifyType(VerifyType.SIGNUP) + .build(); + + doThrow(new AuthException(AuthErrorCode.VERIFY_TYPE_NOT_MATCH)) + .when(authService).verify(givenEmailCertificationForm.username(), + givenEmailCertificationForm.certificationCode(), givenEmailCertificationForm.verifyType()); + + //when + ResultActions resultActions = mockMvc.perform(post("/api/v1/auth/verify") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(givenEmailCertificationForm))); + + //then + resultActions + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(AuthErrorCode.VERIFY_TYPE_NOT_MATCH.getCode())) + .andExpect(jsonPath("$.message").value(AuthErrorCode.VERIFY_TYPE_NOT_MATCH.getMessage())); + } + + @DisplayName("이메일 인증시 회원이 존재하지 않을 때 실패 테스트") + @Test + void verify_member_not_found_fail() throws Exception { + //given + EmailCertificationForm givenEmailCertificationForm = EmailCertificationForm.builder() + .username("testEmail@naver.com") + .certificationCode("testCode") + .verifyType(VerifyType.SIGNUP) + .build(); + + doThrow(new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)) + .when(authService).verify(givenEmailCertificationForm.username(), + givenEmailCertificationForm.certificationCode(), givenEmailCertificationForm.verifyType()); + + //when + ResultActions resultActions = mockMvc.perform(post("/api/v1/auth/verify") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(givenEmailCertificationForm))); + + //then + resultActions + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(MemberErrorCode.MEMBER_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(MemberErrorCode.MEMBER_NOT_FOUND.getMessage())); + } + + @DisplayName("이메일 인증시 이미 인증이 되어 있을 때 실패 테스트") + @Test + void verify_already_certified_fail() throws Exception { + //given + EmailCertificationForm givenEmailCertificationForm = EmailCertificationForm.builder() + .username("testEmail@naver.com") + .certificationCode("testCode") + .verifyType(VerifyType.SIGNUP) + .build(); + + doThrow(new AuthException(AuthErrorCode.ALREADY_CERTIFIED)) + .when(authService).verify(givenEmailCertificationForm.username(), + givenEmailCertificationForm.certificationCode(), givenEmailCertificationForm.verifyType()); + + //when + ResultActions resultActions = mockMvc.perform(post("/api/v1/auth/verify") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(givenEmailCertificationForm))); + + //then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(AuthErrorCode.ALREADY_CERTIFIED.getCode())) + .andExpect(jsonPath("$.message").value(AuthErrorCode.ALREADY_CERTIFIED.getMessage())); + } + + @DisplayName("이메일 인증시 이메일 형식이 틀렸을 때 실패 테스트") + @Test + void verify_email_not_pattern_fail() throws Exception { + //given + EmailCertificationForm givenEmailCertificationForm = EmailCertificationForm.builder() + .username("testEmailnaver.com") + .certificationCode("testCode") + .verifyType(VerifyType.SIGNUP) + .build(); + + //when + ResultActions resultActions = mockMvc.perform(post("/api/v1/auth/verify") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(givenEmailCertificationForm))); + + //then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(GlobalErrorCode.NOT_VALID.getCode())) + .andExpect(jsonPath("$.message").value(GlobalErrorCode.NOT_VALID.getMessage())) + .andExpect(jsonPath("$.errorDetails[0].field").value("username")) + .andExpect(jsonPath("$.errorDetails[0].reason").value("유효하지 않은 이메일 입니다.")); + } + + @DisplayName("이메일 인증시 인증 코드가 비었을 때 실패 테스트") + @Test + void verify_code_not_blank_fail() throws Exception { + //given + EmailCertificationForm givenEmailCertificationForm = EmailCertificationForm.builder() + .username("testEmail@naver.com") + .certificationCode("") + .verifyType(VerifyType.SIGNUP) + .build(); + + //when + ResultActions resultActions = mockMvc.perform(post("/api/v1/auth/verify") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(givenEmailCertificationForm))); + + //then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(GlobalErrorCode.NOT_VALID.getCode())) + .andExpect(jsonPath("$.message").value(GlobalErrorCode.NOT_VALID.getMessage())) + .andExpect(jsonPath("$.errorDetails[0].field").value("certificationCode")) + .andExpect(jsonPath("$.errorDetails[0].reason").value("인증 코드는 필수 항목 입니다.")); + } + + @DisplayName("이메일 인증시 인증 타입이 비었을 때 실패 테스트") + @Test + void verify_verify_type_valid_enum_fail() throws Exception { + //given + EmailCertificationForm givenEmailCertificationForm = EmailCertificationForm.builder() + .username("testEmail@naver.com") + .certificationCode("testCode") + .verifyType(null) + .build(); + + //when + ResultActions resultActions = mockMvc.perform(post("/api/v1/auth/verify") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(givenEmailCertificationForm))); + + //then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(GlobalErrorCode.NOT_VALID.getCode())) + .andExpect(jsonPath("$.message").value(GlobalErrorCode.NOT_VALID.getMessage())) + .andExpect(jsonPath("$.errorDetails[0].field").value("verifyType")) + .andExpect(jsonPath("$.errorDetails[0].reason").value("인증 타입은 필수 항목 입니다.")); + } + + @Test + @DisplayName("로그인 성공") + void login_success() throws Exception { + //given + AuthForm authForm = AuthForm.builder() + .username("user@gmail.com") + .password("Password123!") + .build(); + + AuthResponse authResponse = AuthResponse.of("user@gmail.com", "testNickname", "accessToken", "refreshToken"); + when(authService.login(any(AuthForm.class))).thenReturn(authResponse); + doNothing().when(cookieService).addAccessTokenToCookie(any(String.class), any(HttpServletResponse.class)); + doNothing().when(cookieService).addRefreshTokenToCookie(any(String.class), any(HttpServletResponse.class)); + + //when + ResultActions resultActions = mockMvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(authForm))); + + //then + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.message").value("로그인 성공")) + .andExpect(jsonPath("$.data.username").value("user@gmail.com")); + + verify(authService, times(1)).login(any(AuthForm.class)); + verify(cookieService, times(1)).addAccessTokenToCookie(eq("accessToken"), + any(HttpServletResponse.class)); + verify(cookieService, times(1)).addRefreshTokenToCookie(eq("refreshToken"), + any(HttpServletResponse.class)); + } + + @Test + @DisplayName("로그인 실패 - 입력한 이메일 유저가 존재하지 않을 경우") + void login_fail_member_not_found() throws Exception { + // given + AuthForm authForm = new AuthForm("user@gmail.com", "Password123!"); + + when(authService.login(any(AuthForm.class))).thenThrow(new AuthException(AuthErrorCode.MEMBER_NOT_FOUND)); + + // when + ResultActions resultActions = mockMvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(authForm))); + + // then + resultActions + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(AuthErrorCode.MEMBER_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(AuthErrorCode.MEMBER_NOT_FOUND.getMessage())); + + verify(authService, times(1)).login(any(AuthForm.class)); + verify(cookieService, times(0)).addAccessTokenToCookie(any(String.class), any(HttpServletResponse.class)); + verify(cookieService, times(0)).addRefreshTokenToCookie(any(String.class), any(HttpServletResponse.class)); + } + + @Test + @DisplayName("로그인 실패 - 비밀번호가 일치하지 않는 경우") + void login_fail_password_not_match() throws Exception { + // given + AuthForm authForm = new AuthForm("user@gmail.com", "Password123!"); + + when(authService.login(any(AuthForm.class))).thenThrow(new AuthException(AuthErrorCode.PASSWORD_NOT_MATCH)); + + // when + ResultActions resultActions = mockMvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(authForm))); + + // then + resultActions + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(AuthErrorCode.PASSWORD_NOT_MATCH.getCode())) + .andExpect(jsonPath("$.message").value(AuthErrorCode.PASSWORD_NOT_MATCH.getMessage())); + + verify(authService, times(1)).login(any(AuthForm.class)); + verify(cookieService, times(0)).addAccessTokenToCookie(any(String.class), any(HttpServletResponse.class)); + verify(cookieService, times(0)).addRefreshTokenToCookie(any(String.class), any(HttpServletResponse.class)); + } + + @Test + @DisplayName("로그인 실패 - 이메일이 빈 값일 경우") + void login_fail_email_empty() throws Exception { + // given + AuthForm authForm = new AuthForm("", "Password123!"); + + when(authService.login(any(AuthForm.class))).thenThrow(new GlobalException(GlobalErrorCode.NOT_VALID)); + + // when + ResultActions resultActions = mockMvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(authForm))); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(GlobalErrorCode.NOT_VALID.getCode())) + .andExpect(jsonPath("$.message").value(GlobalErrorCode.NOT_VALID.getMessage())) + .andExpect(jsonPath("$.errorDetails[0].field").value("username")) + .andExpect(jsonPath("$.errorDetails[0].reason").value("유효하지 않은 이메일 입니다.")); + + verify(authService, times(0)).login(any(AuthForm.class)); + verify(cookieService, times(0)).addAccessTokenToCookie(any(String.class), any(HttpServletResponse.class)); + verify(cookieService, times(0)).addRefreshTokenToCookie(any(String.class), any(HttpServletResponse.class)); + } + + @Test + @DisplayName("로그인 실패 - 이메일 형식이 아닐 경우") + void login_fail_not_email() throws Exception { + // given + AuthForm authForm = new AuthForm("", "Password123!"); + + when(authService.login(any(AuthForm.class))).thenThrow(new GlobalException(GlobalErrorCode.NOT_VALID)); + + // when + ResultActions resultActions = mockMvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(authForm))); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(GlobalErrorCode.NOT_VALID.getCode())) + .andExpect(jsonPath("$.message").value(GlobalErrorCode.NOT_VALID.getMessage())) + .andExpect(jsonPath("$.errorDetails[0].field").value("username")) + .andExpect(jsonPath("$.errorDetails[0].reason").value("유효하지 않은 이메일 입니다.")); + + verify(authService, times(0)).login(any(AuthForm.class)); + verify(cookieService, times(0)).addAccessTokenToCookie(any(String.class), any(HttpServletResponse.class)); + verify(cookieService, times(0)).addRefreshTokenToCookie(any(String.class), any(HttpServletResponse.class)); + } + + @Test + @DisplayName("로그아웃 성공 테스트") + @WithMockUser + void logout_success() throws Exception { + // given + String refreshToken = "refreshToken"; + when(cookieService.getRefreshTokenFromRequest(any(HttpServletRequest.class))).thenReturn(refreshToken); + doNothing().when(authService).logout(refreshToken); + doNothing().when(cookieService).deleteRefreshTokenFromCookie(any(HttpServletResponse.class)); + + // when + ResultActions resultActions = mockMvc.perform(post("/api/v1/auth/logout") + .cookie(new Cookie("refreshToken", refreshToken))); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.message").value("로그아웃 성공")) + .andExpect(jsonPath("$.data").isEmpty()); + + verify(cookieService, times(1)).getRefreshTokenFromRequest(any(HttpServletRequest.class)); + verify(authService, times(1)).logout(refreshToken); + verify(cookieService, times(1)).deleteRefreshTokenFromCookie(any(HttpServletResponse.class)); + } + + @DisplayName("인증 코드 이메일 전송 성공 테스트") + @Test + void send_success() throws Exception { + //given + SendEmailCertificationCodeForm givenSendEmailCertificationCodeForm = SendEmailCertificationCodeForm.builder() + .username("testEmail@naver.com") + .verifyType(VerifyType.SIGNUP) + .build(); + + doNothing().when(authService) + .send(givenSendEmailCertificationCodeForm.username(), givenSendEmailCertificationCodeForm.verifyType()); + + //when + ResultActions resultActions = mockMvc.perform(post("/api/v1/auth/code") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(givenSendEmailCertificationCodeForm))); + + //then + resultActions + .andExpect(status().isOk()); + } + + @DisplayName("인증 타입이 일치하지 않을 때 실패 테스트") + @Test + void send_verify_type_not_match_fail() throws Exception { + //given + SendEmailCertificationCodeForm givenSendEmailCertificationCodeForm = SendEmailCertificationCodeForm.builder() + .username("testEmail@naver.com") + .verifyType(VerifyType.SIGNUP) + .build(); + + doThrow(new AuthException(AuthErrorCode.VERIFY_TYPE_NOT_MATCH)) + .when(authService) + .send(givenSendEmailCertificationCodeForm.username(), givenSendEmailCertificationCodeForm.verifyType()); + + //when + ResultActions resultActions = mockMvc.perform(post("/api/v1/auth/code") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(givenSendEmailCertificationCodeForm))); + + //then + resultActions + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(AuthErrorCode.VERIFY_TYPE_NOT_MATCH.getCode())) + .andExpect(jsonPath("$.message").value(AuthErrorCode.VERIFY_TYPE_NOT_MATCH.getMessage())); + } + + @DisplayName("인증 타입이 일치하지 않을 때 실패 테스트") + @Test + void send_too_many_resend_attempts_fail() throws Exception { + //given + SendEmailCertificationCodeForm givenSendEmailCertificationCodeForm = SendEmailCertificationCodeForm.builder() + .username("testEmail@naver.com") + .verifyType(VerifyType.SIGNUP) + .build(); + + doThrow(new AuthException(AuthErrorCode.TOO_MANY_RESEND_ATTEMPTS)) + .when(authService) + .send(givenSendEmailCertificationCodeForm.username(), givenSendEmailCertificationCodeForm.verifyType()); + + //when + ResultActions resultActions = mockMvc.perform(post("/api/v1/auth/code") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(givenSendEmailCertificationCodeForm))); + + //then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(AuthErrorCode.TOO_MANY_RESEND_ATTEMPTS.getCode())) + .andExpect(jsonPath("$.message").value(AuthErrorCode.TOO_MANY_RESEND_ATTEMPTS.getMessage())); + } +} diff --git a/backend/src/test/java/com/example/backend/global/auth/service/AuthServiceTest.java b/backend/src/test/java/com/example/backend/global/auth/service/AuthServiceTest.java new file mode 100644 index 00000000..3d60aa17 --- /dev/null +++ b/backend/src/test/java/com/example/backend/global/auth/service/AuthServiceTest.java @@ -0,0 +1,632 @@ +package com.example.backend.global.auth.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; + +import java.util.Map; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +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 org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; +import org.springframework.security.crypto.password.PasswordEncoder; + +import com.example.backend.domain.common.Address; +import com.example.backend.domain.common.EmailCertification; +import com.example.backend.domain.common.VerifyType; +import com.example.backend.domain.member.dto.MemberDto; +import com.example.backend.domain.member.entity.Member; +import com.example.backend.domain.member.entity.MemberStatus; +import com.example.backend.domain.member.entity.Role; +import com.example.backend.domain.member.exception.MemberErrorCode; +import com.example.backend.domain.member.exception.MemberException; +import com.example.backend.domain.member.repository.MemberRepository; +import com.example.backend.global.auth.dto.AuthForm; +import com.example.backend.global.auth.dto.AuthResponse; +import com.example.backend.global.auth.dto.EmailCertificationForm; +import com.example.backend.global.auth.exception.AuthErrorCode; +import com.example.backend.global.auth.exception.AuthException; +import com.example.backend.global.auth.jwt.JwtProvider; +import com.example.backend.global.auth.jwt.JwtUtils; +import com.example.backend.global.mail.service.MailService; +import com.example.backend.global.mail.util.TemplateName; +import com.example.backend.global.redis.service.RedisService; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; + +@ExtendWith(MockitoExtension.class) +class AuthServiceTest { + + @Mock + private JwtProvider jwtProvider; + + @Mock + private PasswordEncoder passwordEncoder; + + @Mock + private JwtUtils jwtUtils; + + @Mock + private MemberRepository memberRepository; + + @Mock + private RefreshTokenService refreshTokenService; + + @Mock + private RedisService redisService; + + @Mock + private MailService mailService; + + @Mock + private ObjectMapper objectMapper; + + private ObjectMapper testObjectMapper = Jackson2ObjectMapperBuilder + .json().build() + .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); + + @InjectMocks + private AuthService authService; + + @Test + @DisplayName("로그인 성공") + void loginSuccess() { + //given + MemberDto memberDto = MemberDto.builder() + .id(1L) + .username("user@gmail.com") + .password("password") + .role(Role.ROLE_USER) + .memberStatus(MemberStatus.ACTIVE) + .build(); + Member member = Member.from(memberDto); + + when(memberRepository.findByUsername(any(String.class))).thenReturn(Optional.of(member)); + when(passwordEncoder.matches(any(String.class), any(String.class))).thenReturn(true); + when(jwtProvider.generateAccessToken(any(Long.class), any(String.class), any(Role.class))).thenReturn( + "access_token"); + when(jwtProvider.generateRefreshToken(any(Long.class), any(String.class), any(Role.class))).thenReturn( + "refresh_token"); + doNothing().when(refreshTokenService).saveRefreshToken(any(String.class), any(String.class)); + + AuthForm authForm = AuthForm.builder() + .username("user@gmail.com") + .password("password") + .build(); + + //when + AuthResponse result = authService.login(authForm); + + //then + assertThat(result.username()).isEqualTo("user@gmail.com"); + assertThat(result.accessToken()).isEqualTo("access_token"); + assertThat(result.refreshToken()).isEqualTo("refresh_token"); + verify(memberRepository).findByUsername("user@gmail.com"); + verify(passwordEncoder).matches("password", "password"); + verify(jwtProvider).generateAccessToken(1L, "user@gmail.com", Role.ROLE_USER); + verify(jwtProvider).generateRefreshToken(1L, "user@gmail.com", Role.ROLE_USER); + verify(refreshTokenService).saveRefreshToken("user@gmail.com", "refresh_token"); + } + + @Test + @DisplayName("로그인 실패 - 이메일 미인증일 때") + void loginFail_memberStatus_pending() { + // given + MemberDto memberDto = MemberDto.builder() + .id(1L) + .username("user@gmail.com") + .password("password") + .role(Role.ROLE_USER) + .memberStatus(MemberStatus.PENDING) + .build(); + Member member = Member.from(memberDto); + + when(passwordEncoder.matches(any(String.class), any(String.class))).thenReturn(true); + when(memberRepository.findByUsername("user@gmail.com")) + .thenReturn(Optional.of(member)); + + AuthForm authForm = AuthForm.builder() + .username("user@gmail.com") + .password("password") + .build(); + + // when & then + assertThatThrownBy(() -> authService.login(authForm)) + .isInstanceOf(AuthException.class) + .hasMessage(AuthErrorCode.NOT_CERTIFICATION.getMessage()); + + verify(memberRepository).findByUsername("user@gmail.com"); + } + + @Test + @DisplayName("로그인 실패 - 해당 유저가 없음") + void loginFail_UserNotFound() { + // given + when(memberRepository.findByUsername("user@gmail.com")) + .thenReturn(Optional.empty()); + + AuthForm authForm = AuthForm.builder() + .username("user@gmail.com") + .password("password") + .build(); + + // when & then + assertThatThrownBy(() -> authService.login(authForm)) + .isInstanceOf(AuthException.class) + .hasMessage(AuthErrorCode.MEMBER_NOT_FOUND.getMessage()); + + verify(memberRepository).findByUsername("user@gmail.com"); + } + + @Test + @DisplayName("로그인 실패 - 비밀번호 불일치") + void loginFail_PasswordNotMatch() { + // given + MemberDto memberDto = MemberDto.builder() + .id(1L) + .username("user@gmail.com") + .password("password") + .role(Role.ROLE_USER) + .build(); + Member member = Member.from(memberDto); + + when(memberRepository.findByUsername("user@gmail.com")).thenReturn(Optional.of(member)); + when(passwordEncoder.matches("pw", "password")).thenReturn(false); + + AuthForm authForm = AuthForm.builder() + .username("user@gmail.com") + .password("pw") + .build(); + + // when & then + assertThatThrownBy(() -> authService.login(authForm)) + .isInstanceOf(AuthException.class) + .hasMessage(AuthErrorCode.PASSWORD_NOT_MATCH.getMessage()); + + verify(memberRepository).findByUsername("user@gmail.com"); + verify(passwordEncoder).matches("pw", "password"); + } + + @DisplayName("이메일 인증 성공 테스트") + @Test + void verify_success() { + //given + String givenRedisPrefix = "certification_email:"; + + Address givenAddress = Address.builder() + .city("testCity") + .detail("testDetail") + .country("testCountry") + .district("testDistrict") + .build(); + + Member givenMember = Member.builder() + .username("testEmail@naver.com") + .nickname("testNickName") + .password("!testPassword1234") + .address(givenAddress) + .memberStatus(MemberStatus.PENDING) + .role(Role.ROLE_USER) + .build(); + + Member verifyMember = Member.builder() + .username("testEmail@naver.com") + .nickname("testNickName") + .password("!testPassword1234") + .address(givenAddress) + .memberStatus(MemberStatus.ACTIVE) + .role(Role.ROLE_USER) + .build(); + + EmailCertificationForm givenEmailCertificationForm = EmailCertificationForm.builder() + .certificationCode("testCode") + .verifyType(VerifyType.SIGNUP) + .username("testEmail@naver.com") + .build(); + + EmailCertification givenEmailCertification = EmailCertification.builder() + .certificationCode(givenEmailCertificationForm.certificationCode()) + .verifyType(givenEmailCertificationForm.verifyType().toString()) + .sendCount("1") + .build(); + + Map givenConvertMap = testObjectMapper.convertValue(givenEmailCertification, Map.class); + + given(redisService.getHashDataAll(givenRedisPrefix + givenEmailCertificationForm.username())) + .willReturn(givenConvertMap); + + given(objectMapper.convertValue(givenConvertMap, EmailCertification.class)).willReturn(givenEmailCertification); + + given(memberRepository.findByUsername(givenEmailCertificationForm.username())) + .willReturn(Optional.of(givenMember)); + + given(memberRepository.save(any(Member.class))).willReturn(verifyMember); + + doNothing().when(redisService).delete(givenRedisPrefix + givenEmailCertificationForm.username()); + + //when + authService.verify(givenEmailCertificationForm.username(), + givenEmailCertificationForm.certificationCode(), + givenEmailCertificationForm.verifyType()); + + //then + verify(redisService, times(1)).getHashDataAll(givenRedisPrefix + givenEmailCertificationForm.username()); + verify(memberRepository, times(1)).findByUsername(givenEmailCertificationForm.username()); + verify(memberRepository, times(1)).save(any(Member.class)); + verify(redisService, times(1)).delete(givenRedisPrefix + givenEmailCertificationForm.username()); + } + + @DisplayName("이메일 인증시 인증 정보 없을 때 실패 테스트") + @Test + void verify_emailCertification_not_found_fail() { + //given + String givenRedisPrefix = "certification_email:"; + + Address givenAddress = Address.builder() + .city("testCity") + .detail("testDetail") + .country("testCountry") + .district("testDistrict") + .build(); + + Member givenMember = Member.builder() + .username("testEmail@naver.com") + .nickname("testNickName") + .password("!testPassword1234") + .address(givenAddress) + .memberStatus(MemberStatus.PENDING) + .role(Role.ROLE_USER) + .build(); + + EmailCertificationForm givenEmailCertificationForm = EmailCertificationForm.builder() + .certificationCode("testCode") + .verifyType(VerifyType.SIGNUP) + .username("testEmail@naver.com") + .build(); + + given(redisService.getHashDataAll(givenRedisPrefix + givenEmailCertificationForm.username())) + .willReturn(Map.of()); + + given(memberRepository.findByUsername(givenMember.getUsername())).willReturn(Optional.of(givenMember)); + + //when & then + assertThatThrownBy(() -> authService.verify(givenEmailCertificationForm.username(), + givenEmailCertificationForm.certificationCode(), givenEmailCertificationForm.verifyType())) + .isInstanceOf(AuthException.class) + .hasMessage(AuthErrorCode.CERTIFICATION_CODE_NOT_FOUND.getMessage()); + } + + @DisplayName("이메일 인증시 인증 타입이 일치하지 않을 때 실패 테스트") + @Test + void verify_verify_type_not_match_fail() { + //given + String givenRedisPrefix = "certification_email:"; + + Address givenAddress = Address.builder() + .city("testCity") + .detail("testDetail") + .country("testCountry") + .district("testDistrict") + .build(); + + Member givenMember = Member.builder() + .username("testEmail@naver.com") + .nickname("testNickName") + .password("!testPassword1234") + .address(givenAddress) + .memberStatus(MemberStatus.PENDING) + .role(Role.ROLE_USER) + .build(); + + EmailCertificationForm givenEmailCertificationForm = EmailCertificationForm.builder() + .certificationCode("testCode") + .verifyType(VerifyType.SIGNUP) + .username("testEmail@naver.com") + .build(); + + EmailCertification givenEmailCertification = EmailCertification.builder() + .verifyType(VerifyType.PASSWORD_RESET.toString()) + .certificationCode(givenEmailCertificationForm.certificationCode()) + .sendCount("1") + .build(); + + Map givenConvertMap = testObjectMapper.convertValue(givenEmailCertification, Map.class); + + given(memberRepository.findByUsername(givenMember.getUsername())).willReturn(Optional.of(givenMember)); + + given(redisService.getHashDataAll(givenRedisPrefix + givenEmailCertificationForm.username())) + .willReturn(givenConvertMap); + + given(objectMapper.convertValue(givenConvertMap, EmailCertification.class)).willReturn(givenEmailCertification); + + //when & then + assertThatThrownBy(() -> authService.verify(givenEmailCertificationForm.username(), + givenEmailCertificationForm.certificationCode(), givenEmailCertificationForm.verifyType())) + .isInstanceOf(AuthException.class) + .hasMessage(AuthErrorCode.VERIFY_TYPE_NOT_MATCH.getMessage()); + } + + @DisplayName("이메일 인증시 인증 코드가 일치하지 않을 때 실패 테스트") + @Test + void verify_certification_not_match_fail() { + //given + String givenRedisPrefix = "certification_email:"; + + Address givenAddress = Address.builder() + .city("testCity") + .detail("testDetail") + .country("testCountry") + .district("testDistrict") + .build(); + + Member givenMember = Member.builder() + .username("testEmail@naver.com") + .nickname("testNickName") + .password("!testPassword1234") + .address(givenAddress) + .memberStatus(MemberStatus.PENDING) + .role(Role.ROLE_USER) + .build(); + + EmailCertificationForm givenEmailCertificationForm = EmailCertificationForm.builder() + .certificationCode("testCode") + .verifyType(VerifyType.SIGNUP) + .username("testEmail@naver.com") + .build(); + + EmailCertification givenEmailCertification = EmailCertification.builder() + .verifyType(givenEmailCertificationForm.verifyType().toString()) + .certificationCode("notMatchCode") + .sendCount("1") + .build(); + + Map givenConvertMap = testObjectMapper.convertValue(givenEmailCertification, Map.class); + + given(memberRepository.findByUsername(givenMember.getUsername())).willReturn(Optional.of(givenMember)); + + given(redisService.getHashDataAll(givenRedisPrefix + givenEmailCertificationForm.username())) + .willReturn(givenConvertMap); + + given(objectMapper.convertValue(givenConvertMap, EmailCertification.class)).willReturn(givenEmailCertification); + + //when & then + assertThatThrownBy(() -> authService.verify(givenEmailCertificationForm.username(), + givenEmailCertificationForm.certificationCode(), givenEmailCertificationForm.verifyType())) + .isInstanceOf(AuthException.class) + .hasMessage(AuthErrorCode.CERTIFICATION_CODE_NOT_MATCH.getMessage()); + } + + @DisplayName("이메일 인증시 회원 조회 실패 테스트") + @Test + void verify_member_not_found_fail() { + //given + String givenRedisPrefix = "certification_email:"; + + Address givenAddress = Address.builder() + .city("testCity") + .detail("testDetail") + .country("testCountry") + .district("testDistrict") + .build(); + + Member givenMember = Member.builder() + .username("testEmail@naver.com") + .nickname("testNickName") + .password("!testPassword1234") + .address(givenAddress) + .memberStatus(MemberStatus.PENDING) + .role(Role.ROLE_USER) + .build(); + + EmailCertificationForm givenEmailCertificationForm = EmailCertificationForm.builder() + .certificationCode("testCode") + .verifyType(VerifyType.SIGNUP) + .username("testEmail@naver.com") + .build(); + + EmailCertification givenEmailCertification = EmailCertification.builder() + .certificationCode(givenEmailCertificationForm.certificationCode()) + .verifyType(givenEmailCertificationForm.verifyType().toString()) + .sendCount("1") + .build(); + + given(memberRepository.findByUsername(givenEmailCertificationForm.username())) + .willReturn(Optional.empty()); + + //when & then + assertThatThrownBy(() -> authService.verify(givenEmailCertificationForm.username(), + givenEmailCertificationForm.certificationCode(), givenEmailCertificationForm.verifyType())) + .isInstanceOf(MemberException.class) + .hasMessage(MemberErrorCode.MEMBER_NOT_FOUND.getMessage()); + } + + @DisplayName("이메일 인증시 이미 인증이 되어 있을 때 실패 테스트") + @Test + void verify_already_certified_fail() { + //given + String givenRedisPrefix = "certification_email:"; + Address givenAddress = Address.builder() + .city("testCity") + .detail("testDetail") + .country("testCountry") + .district("testDistrict") + .build(); + + MemberDto givenMember = MemberDto.builder() + .username("testEmail@naver.com") + .nickname("testNickName") + .password("!testPassword1234") + .address(givenAddress) + .memberStatus(MemberStatus.ACTIVE) + .role(Role.ROLE_USER) + .build(); + + EmailCertificationForm givenEmailCertificationForm = EmailCertificationForm.builder() + .certificationCode("testCode") + .verifyType(VerifyType.SIGNUP) + .username("testEmail@naver.com") + .build(); + + EmailCertification givenEmailCertification = EmailCertification.builder() + .certificationCode(givenEmailCertificationForm.certificationCode()) + .verifyType(givenEmailCertificationForm.verifyType().toString()) + .sendCount("1") + .build(); + + given(memberRepository.findByUsername(givenEmailCertificationForm.username())) + .willReturn(Optional.of(Member.from(givenMember))); + + //when & then + assertThatThrownBy(() -> authService.verify(givenEmailCertificationForm.username(), + givenEmailCertificationForm.certificationCode(), givenEmailCertificationForm.verifyType())) + .isInstanceOf(AuthException.class) + .hasMessage(AuthErrorCode.ALREADY_CERTIFIED.getMessage()); + } + + @Test + @DisplayName("로그아웃 성공 시 리프레시 토큰 레디스에서 삭제") + void logoutSuccess() { + //given + String accessToken = "accessToken"; + String username = "user@gmail.com"; + when(jwtUtils.getUsernameFromToken(any(String.class))).thenReturn(username); + + //when + authService.logout(accessToken); + + //then + verify(jwtUtils).getUsernameFromToken(accessToken); + verify(refreshTokenService).deleteRefreshToken(username); + } + + @DisplayName("이메일 인증 코드 발송 성공 테스트") + @Test + void send_success() { + //given + String givenUsername = "testEmail@naver.com"; + VerifyType givenVerifyType = VerifyType.PASSWORD_RESET; + + Address givenAddress = Address.builder() + .city("testCity") + .detail("testDetail") + .country("testCountry") + .district("testDistrict") + .build(); + + Member givenMember = Member.builder() + .username(givenUsername) + .nickname("testNickName") + .password("!testPassword1234") + .address(givenAddress) + .memberStatus(MemberStatus.ACTIVE) + .role(Role.ROLE_USER) + .build(); + + EmailCertification givenEmailCertification = EmailCertification.builder() + .verifyType(VerifyType.PASSWORD_RESET.toString()) + .certificationCode("testCode") + .sendCount("1") + .build(); + + Map givenConvertMap = testObjectMapper.convertValue(givenEmailCertification, Map.class); + + doNothing().when(mailService).sendCertificationMail(any(String.class), any(EmailCertification.class), any( + TemplateName.class)); + given(memberRepository.findByUsername(givenUsername)).willReturn(Optional.of(givenMember)); + + //when + authService.send(givenUsername, givenVerifyType); + + //then + verify(mailService, times(1)) + .sendCertificationMail(any(String.class), any(EmailCertification.class), any(TemplateName.class)); + verify(memberRepository, times(1)).findByUsername(givenUsername); + } + + @DisplayName("이메일 인증 코드 재발송시 인증 타입이 일치하지 않을 때 실패 테스트") + @Test + void send_verify_type_not_match_fail() { + //given + String givenUsername = "testEmail@naver.com"; + VerifyType givenVerifyType = VerifyType.PASSWORD_RESET; + + Address givenAddress = Address.builder() + .city("testCity") + .detail("testDetail") + .country("testCountry") + .district("testDistrict") + .build(); + + Member givenMember = Member.builder() + .username(givenUsername) + .nickname("testNickName") + .password("!testPassword1234") + .address(givenAddress) + .memberStatus(MemberStatus.ACTIVE) + .role(Role.ROLE_USER) + .build(); + + EmailCertification givenEmailCertification = EmailCertification.builder() + .verifyType(VerifyType.SIGNUP.toString()) + .certificationCode("testCode") + .sendCount("5") + .build(); + + Map givenConvertMap = testObjectMapper.convertValue(givenEmailCertification, Map.class); + + given(memberRepository.findByUsername(givenUsername)).willReturn(Optional.of(givenMember)); + given(redisService.getHashDataAll("certification_email:" + givenUsername)).willReturn(givenConvertMap); + given(objectMapper.convertValue(givenConvertMap, EmailCertification.class)).willReturn(givenEmailCertification); + + //when & then + assertThatThrownBy(() -> authService.send(givenUsername, givenVerifyType)) + .isInstanceOf(AuthException.class) + .hasMessage(AuthErrorCode.VERIFY_TYPE_NOT_MATCH.getMessage()); + } + + @DisplayName("이메일 인증 코드 재발송시 10분 이내 5회 이상 전송했을 때 실패 테스트") + @Test + void send_too_many_resend_attempts_fail() { + //given + String givenUsername = "testEmail@naver.com"; + VerifyType givenVerifyType = VerifyType.PASSWORD_RESET; + + Address givenAddress = Address.builder() + .city("testCity") + .detail("testDetail") + .country("testCountry") + .district("testDistrict") + .build(); + + Member givenMember = Member.builder() + .username(givenUsername) + .nickname("testNickName") + .password("!testPassword1234") + .address(givenAddress) + .memberStatus(MemberStatus.ACTIVE) + .role(Role.ROLE_USER) + .build(); + + EmailCertification givenEmailCertification = EmailCertification.builder() + .verifyType(VerifyType.PASSWORD_RESET.toString()) + .certificationCode("testCode") + .sendCount("5") + .build(); + + Map givenConvertMap = testObjectMapper.convertValue(givenEmailCertification, Map.class); + + given(memberRepository.findByUsername(givenUsername)).willReturn(Optional.of(givenMember)); + given(redisService.getHashDataAll("certification_email:" + givenUsername)).willReturn(givenConvertMap); + given(objectMapper.convertValue(givenConvertMap, EmailCertification.class)).willReturn(givenEmailCertification); + + //when & then + assertThatThrownBy(() -> authService.send(givenUsername, givenVerifyType)) + .isInstanceOf(AuthException.class) + .hasMessage(AuthErrorCode.TOO_MANY_RESEND_ATTEMPTS.getMessage()); + } +} diff --git a/backend/src/test/java/com/example/backend/global/auth/service/CookieServiceTest.java b/backend/src/test/java/com/example/backend/global/auth/service/CookieServiceTest.java new file mode 100644 index 00000000..3708d55d --- /dev/null +++ b/backend/src/test/java/com/example/backend/global/auth/service/CookieServiceTest.java @@ -0,0 +1,116 @@ +package com.example.backend.global.auth.service; + +import static org.mockito.BDDMockito.*; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +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 com.example.backend.global.auth.util.CookieUtils; +import com.example.backend.global.config.JwtConfig; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +@ExtendWith(MockitoExtension.class) +class CookieServiceTest { + + @Mock + private CookieUtils cookieUtils; + + @Mock + private JwtConfig jwtConfig; + + @Mock + private HttpServletRequest request; + + @Mock + private HttpServletResponse response; + + @InjectMocks + private CookieService cookieService; + + @Test + @DisplayName("Http 요청에서 액세스 토큰 값 추출") + void getAccessTokenFromRequest() { + // given + String expectedToken = "mockAccessToken"; + given(cookieUtils.getTokenFromRequest(request, "accessToken")).willReturn(expectedToken); + + // when + String actualToken = cookieService.getAccessTokenFromRequest(request); + + // then + Assertions.assertThat(actualToken).isEqualTo(expectedToken); + verify(cookieUtils).getTokenFromRequest(request, "accessToken"); + } + + @Test + @DisplayName("Http 요청에서 리프레시 토큰 값 추출") + void getRefreshTokenFromRequest() { + // given + String expectedToken = "mockRefreshToken"; + given(cookieUtils.getTokenFromRequest(request, "refreshToken")).willReturn(expectedToken); + + // when + String actualToken = cookieService.getRefreshTokenFromRequest(request); + + // then + Assertions.assertThat(actualToken).isEqualTo(expectedToken); + verify(cookieUtils).getTokenFromRequest(request, "refreshToken"); + } + + @Test + @DisplayName("액세스 토큰 쿠키 저장") + void addAccessTokenToCookie() { + // given + String accessToken = "mockAccessToken"; + long expirationTime = 3600L; + given(jwtConfig.getAccessTokenExpirationTimeInSeconds()).willReturn(expirationTime); + + // when + cookieService.addAccessTokenToCookie(accessToken, response); + + // then + verify(cookieUtils).addTokenToCookie("accessToken", accessToken, expirationTime, response); + } + + @Test + @DisplayName("리프레시 토큰 쿠키 저장") + void addRefreshTokenToCookie() { + // given + String refreshToken = "mockRefreshToken"; + long expirationTime = 3600L; + given(jwtConfig.getRefreshTokenExpirationTimeInSeconds()).willReturn(expirationTime); + + // when + cookieService.addRefreshTokenToCookie(refreshToken, response); + + // then + verify(cookieUtils).addTokenToCookie("refreshToken", refreshToken, expirationTime, response); + } + + @Test + @DisplayName("액세스 토큰 쿠키 삭제") + void deleteAccessTokenFromCookie() { + // When + cookieService.deleteAccessTokenFromCookie(response); + + // Then + verify(cookieUtils).addTokenToCookie("accessToken", null, 0L, response); + } + + @Test + @DisplayName("리프레시 토큰 쿠키 삭제") + void deleteRefreshTokenFromCookie() { + // When + cookieService.deleteRefreshTokenFromCookie(response); + + // Then + verify(cookieUtils).addTokenToCookie("refreshToken", null, 0L, response); + } +} \ No newline at end of file diff --git a/backend/src/test/java/com/example/backend/global/config/TestSecurityConfig.java b/backend/src/test/java/com/example/backend/global/config/TestSecurityConfig.java new file mode 100644 index 00000000..6d270142 --- /dev/null +++ b/backend/src/test/java/com/example/backend/global/config/TestSecurityConfig.java @@ -0,0 +1,55 @@ +package com.example.backend.global.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; + +/** + * TestSecurityConfig + *

+ * + * @author 100mi + */ +@Configuration +@RequiredArgsConstructor +@EnableWebSecurity +@Profile("test") +public class TestSecurityConfig { + + private final CorsConfig corsConfig; + + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy( + SessionCreationPolicy.STATELESS)) + .addFilter(corsConfig.corsFilter()) + .authorizeHttpRequests(authorizeRequests -> authorizeRequests + .requestMatchers("/api/v1/members/join").permitAll() + .requestMatchers("/api/v1/members/**").hasAnyRole("USER", "ADMIN") + .requestMatchers("/api/v1/auth/login", "/api/v1/auth/code", "api/v1/auth/verify").permitAll() + .requestMatchers("/api/v1/auth/**").hasAnyRole("USER", "ADMIN") + .requestMatchers(HttpMethod.GET, "/api/v1/products/**").permitAll() + .requestMatchers("/api/v1/products/**").hasAnyRole("ADMIN") + .requestMatchers("/api/v1/orders/**").hasAnyRole("USER", "ADMIN") + .requestMatchers("/api/v1/carts/**").hasAnyRole("USER", "ADMIN")) + ; + return http.build(); + } + + @Bean + PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 00000000..e352149e --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,43 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions +*.iml +*.xml + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/frontend/.idea/.gitignore b/frontend/.idea/.gitignore new file mode 100644 index 00000000..13566b81 --- /dev/null +++ b/frontend/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 00000000..e215bc4c --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/frontend/next.config.ts b/frontend/next.config.ts new file mode 100644 index 00000000..d43d7a0f --- /dev/null +++ b/frontend/next.config.ts @@ -0,0 +1,10 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + /* config options here */ + images: { + domains: ['imgur.com', 'i.imgur.com', 'images.unsplash.com', "example.com"], // imgur 이미지를 허용하기 위한 설정 + }, +}; + +export default nextConfig; diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 00000000..eff26a88 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2203 @@ +{ + "name": "frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.1.0", + "dependencies": { + "next": "15.1.4", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "postcss": "^8", + "tailwindcss": "^3.4.1", + "typescript": "^5" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.3.1.tgz", + "integrity": "sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", + "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "dev": true, + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@next/env": { + "version": "15.1.4", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.1.4.tgz", + "integrity": "sha512-2fZ5YZjedi5AGaeoaC0B20zGntEHRhi2SdWcu61i48BllODcAmmtj8n7YarSPt4DaTsJaBFdxQAVEVzgmx2Zpw==" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.1.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.1.4.tgz", + "integrity": "sha512-wBEMBs+np+R5ozN1F8Y8d/Dycns2COhRnkxRc+rvnbXke5uZBHkUGFgWxfTXn5rx7OLijuUhyfB+gC/ap58dDw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.1.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.1.4.tgz", + "integrity": "sha512-7sgf5rM7Z81V9w48F02Zz6DgEJulavC0jadab4ZsJ+K2sxMNK0/BtF8J8J3CxnsJN3DGcIdC260wEKssKTukUw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.1.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.1.4.tgz", + "integrity": "sha512-JaZlIMNaJenfd55kjaLWMfok+vWBlcRxqnRoZrhFQrhM1uAehP3R0+Aoe+bZOogqlZvAz53nY/k3ZyuKDtT2zQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.1.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.1.4.tgz", + "integrity": "sha512-7EBBjNoyTO2ipMDgCiORpwwOf5tIueFntKjcN3NK+GAQD7OzFJe84p7a2eQUeWdpzZvhVXuAtIen8QcH71ZCOQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.1.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.1.4.tgz", + "integrity": "sha512-9TGEgOycqZFuADyFqwmK/9g6S0FYZ3tphR4ebcmCwhL8Y12FW8pIBKJvSwV+UBjMkokstGNH+9F8F031JZKpHw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.1.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.1.4.tgz", + "integrity": "sha512-0578bLRVDJOh+LdIoKvgNDz77+Bd85c5JrFgnlbI1SM3WmEQvsjxTA8ATu9Z9FCiIS/AliVAW2DV/BDwpXbtiQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.1.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.1.4.tgz", + "integrity": "sha512-JgFCiV4libQavwII+kncMCl30st0JVxpPOtzWcAI2jtum4HjYaclobKhj+JsRu5tFqMtA5CJIa0MvYyuu9xjjQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.1.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.1.4.tgz", + "integrity": "sha512-xxsJy9wzq7FR5SqPCUqdgSXiNXrMuidgckBa8nH9HtjjxsilgcN6VgXF6tZ3uEWuVEadotQJI8/9EQ6guTC4Yw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@types/node": { + "version": "20.17.14", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.14.tgz", + "integrity": "sha512-w6qdYetNL5KRBiSClK/KWai+2IMEJuAj+EujKCumalFOwXtvOXaEan9AuwcRID2IcOIAWSIfR495hBtgKlx2zg==", + "dev": true, + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/@types/react": { + "version": "19.0.7", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.7.tgz", + "integrity": "sha512-MoFsEJKkAtZCrC1r6CM8U22GzhG7u2Wir8ons/aCKH6MBdD1ibV24zOSSkdZVUKqN5i396zG5VKLYZ3yaUZdLA==", + "dev": true, + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.0.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.0.3.tgz", + "integrity": "sha512-0Knk+HJiMP/qOZgMyNFamlIjw9OFCsyC2ZbigmEEyXXixgre6IQpm/4V+r3qH4GC1JPvRJKInw+on2rV6YZLeA==", + "dev": true, + "peerDependencies": { + "@types/react": "^19.0.0" + } + }, + "node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001692", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001692.tgz", + "integrity": "sha512-A95VKan0kdtrsnMubMKxEKUKImOPSuCpYgxSQBo036P5YYgVIcOYJEgt/txJWqObiRQeISNCfef9nvlQ0vbV7A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==" + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "optional": true, + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "devOptional": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "devOptional": true + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "optional": true, + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true + }, + "node_modules/detect-libc": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", + "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.18.0.tgz", + "integrity": "sha512-QKHXPW0hD8g4UET03SdOdunzSouc9N4AuHdsX8XNcTsuz+yYFILVNIX4l9yHABMhiEI9Db0JTTIpu0wB+Y1QQw==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/foreground-child": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", + "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", + "optional": true + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "15.1.4", + "resolved": "https://registry.npmjs.org/next/-/next-15.1.4.tgz", + "integrity": "sha512-mTaq9dwaSuwwOrcu3ebjDYObekkxRnXpuVL21zotM8qE2W0HBOdVIdg2Li9QjMEZrj73LN96LcWcz62V19FjAg==", + "dependencies": { + "@next/env": "15.1.4", + "@swc/counter": "0.1.3", + "@swc/helpers": "0.5.15", + "busboy": "1.6.0", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "15.1.4", + "@next/swc-darwin-x64": "15.1.4", + "@next/swc-linux-arm64-gnu": "15.1.4", + "@next/swc-linux-arm64-musl": "15.1.4", + "@next/swc-linux-x64-gnu": "15.1.4", + "@next/swc-linux-x64-musl": "15.1.4", + "@next/swc-win32-arm64-msvc": "15.1.4", + "@next/swc-win32-x64-msvc": "15.1.4", + "sharp": "^0.33.5" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.41.2", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", + "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.1.tgz", + "integrity": "sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.8", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "dev": true, + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/react": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", + "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz", + "integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==", + "dependencies": { + "scheduler": "^0.25.0" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", + "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==" + }, + "node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "optional": true, + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", + "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", + "dev": true, + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.6", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, + "node_modules/typescript": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yaml": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz", + "integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==", + "dev": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 00000000..be10c71d --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,24 @@ +{ + "name": "frontend", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "next": "15.1.4" + }, + "devDependencies": { + "typescript": "^5", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "postcss": "^8", + "tailwindcss": "^3.4.1" + } +} diff --git a/frontend/postcss.config.mjs b/frontend/postcss.config.mjs new file mode 100644 index 00000000..1a69fd2a --- /dev/null +++ b/frontend/postcss.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + tailwindcss: {}, + }, +}; + +export default config; diff --git a/frontend/public/file.svg b/frontend/public/file.svg new file mode 100644 index 00000000..004145cd --- /dev/null +++ b/frontend/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/globe.svg b/frontend/public/globe.svg new file mode 100644 index 00000000..567f17b0 --- /dev/null +++ b/frontend/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/next.svg b/frontend/public/next.svg new file mode 100644 index 00000000..5174b28c --- /dev/null +++ b/frontend/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/vercel.svg b/frontend/public/vercel.svg new file mode 100644 index 00000000..77053960 --- /dev/null +++ b/frontend/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/window.svg b/frontend/public/window.svg new file mode 100644 index 00000000..b2b2a44f --- /dev/null +++ b/frontend/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx new file mode 100644 index 00000000..d62c6c92 --- /dev/null +++ b/frontend/src/app/admin/page.tsx @@ -0,0 +1,48 @@ +"use client"; + +import Link from "next/link"; + +const AdminPage = () => { + return ( +
+

Admin Page

+ +
+

관리자 작업

+
+ {/* 상품 등록 버튼 */} +
+ + + +
+ + {/* 상품 수정 버튼 */} +
+ + + +
+ + {/* 상품 삭제 버튼 */} +
+ + + +
+
+
+
+ ); +}; + +export default AdminPage; \ No newline at end of file diff --git a/frontend/src/app/admin/product/create/page.tsx b/frontend/src/app/admin/product/create/page.tsx new file mode 100644 index 00000000..01e738af --- /dev/null +++ b/frontend/src/app/admin/product/create/page.tsx @@ -0,0 +1,38 @@ +"use client"; + +import ProductForm from "../../../components/ProductForm"; +import {useRouter} from "next/navigation"; + +export default function ProductCreate() { + const router = useRouter(); + const handleSubmit = async (formData: { + name: string; + content: string; + price: number; + imgUrl: string; + quantity: number; + }) => { + const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/products`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(formData), + credentials: "include", + }); + + if (response.ok) { + router.push(`/admin`); + } else { + const errorData = await response.json(); + throw new Error(`오류 발생: ${errorData.message || "알 수 없는 오류"}`); + } + }; + + return ( +
+

상품 등록

+ +
+ ); +} \ No newline at end of file diff --git a/frontend/src/app/admin/product/delete/page.tsx b/frontend/src/app/admin/product/delete/page.tsx new file mode 100644 index 00000000..941e53bb --- /dev/null +++ b/frontend/src/app/admin/product/delete/page.tsx @@ -0,0 +1,105 @@ +"use client"; + +import Pagination from '../../../components/Pagination'; +import { useProductsWithPagination } from '@/app/hooks/useProductsWithPagination'; +import Image from 'next/image'; + +export default function ProductDeleteList() { + const { + products, + currentPage, + totalPages, + isLoading, + error, + handlePageChange, + } = useProductsWithPagination(); + + const handleDelete = async (productId: number, productName: string) => { + const confirmDelete = window.confirm( + `${productName} 상품을 정말 삭제하시겠습니까?` + ); + + if (!confirmDelete) { + return; // 사용자가 삭제를 취소한 경우 + } + + try { + const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/products/${productId}`, { + method: 'DELETE', + credentials: "include", + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.message || '상품 삭제에 실패했습니다.'); + } + + alert('상품이 성공적으로 삭제되었습니다.'); + // 삭제 성공 후 새로고침 + window.location.reload(); + } catch (error) { + console.error(error); + alert(error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'); + } + }; + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + return ( +
+

삭제할 상품 선택

+ {(!products || products.length === 0) ? ( +

등록된 상품이 없습니다.

+ ) : ( + <> +
    + {products.map((product) => ( +
  • +
    + {product.name} +
    +
    + +

    + {product.price.toLocaleString()}원 +

    +
    +
  • + ))} +
+ {totalPages > 1 && ( + + )} + + )} +
+ ); +} diff --git a/frontend/src/app/admin/product/modify/[id]/page.tsx b/frontend/src/app/admin/product/modify/[id]/page.tsx new file mode 100644 index 00000000..9afcf76d --- /dev/null +++ b/frontend/src/app/admin/product/modify/[id]/page.tsx @@ -0,0 +1,68 @@ +"use client"; + +import {useEffect, useState} from "react"; +import {useRouter} from "next/navigation"; +import {useProduct} from "@/app/hooks/useProduct"; +import {use} from 'react'; +import ProductForm from "../../../../components/ProductForm"; + +export default function ProductModify({params}: { params: Promise<{ id: string }> }) { + const unwrappedParams = use(params); + const productId = parseInt(unwrappedParams.id); + const router = useRouter(); + + const product = useProduct(productId); + const [initialData, setInitialData] = useState<{ + name: string; + content: string; + price: string; + imgUrl: string; + quantity: string; + } | undefined>(undefined); + + useEffect(() => { + if (product) { + setInitialData({ + name: product.name, + content: product.content, + price: product.price.toString(), + imgUrl: product.imgUrl, + quantity: "", + }); + } + }, [product]); + + const handleSubmit = async (formData: { + name: string; + content: string; + price: number; + imgUrl: string; + quantity: number; + }) => { + const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/products/${productId}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(formData), + credentials: "include", + }); + + if (!response.ok) { + throw new Error('Failed to update product'); + } + + const data = await response.json(); + + await router.push(`/product/${productId}`); + }; + + return ( +
+

상품 수정

+ + + +
+ ); +} \ No newline at end of file diff --git a/frontend/src/app/admin/product/modify/page.tsx b/frontend/src/app/admin/product/modify/page.tsx new file mode 100644 index 00000000..207a737d --- /dev/null +++ b/frontend/src/app/admin/product/modify/page.tsx @@ -0,0 +1,78 @@ +"use client"; + +import Pagination from '../../../components/Pagination'; +import {useProductsWithPagination} from '@/app/hooks/useProductsWithPagination'; +import Link from 'next/link'; +import Image from 'next/image'; + +export default function ProductModifyList() { + const { + products, + currentPage, + totalPages, + isLoading, + error, + handlePageChange, + } = useProductsWithPagination(); + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + return ( +
+

수정할 상품 선택

+ {(!products || products.length === 0) ? ( +

등록된 상품이 없습니다.

+ ) : ( + <> +
    + {products.map(product => ( +
  • +
    + {product.name} +
    +
    + + {product.name} + +

    + {product.price.toLocaleString()}원 +

    +
    +
  • + ))} +
+ {totalPages > 1 && ( + + )} + + )} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/app/cart/page.tsx b/frontend/src/app/cart/page.tsx new file mode 100644 index 00000000..c8af632f --- /dev/null +++ b/frontend/src/app/cart/page.tsx @@ -0,0 +1,148 @@ +"use client"; +import { useState, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; + +interface CartItem { + id: number; + productId: number; + productName: string; + quantity: number; + productPrice: number; + totalPrice: number; + productImgUrl: string; +} + +const Page = () => { + const [cartItems, setCartItems] = useState([]); + const navigate = useRouter(); + + useEffect(() => { + const fetchCartData = async () => { + const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/carts`, { + method: 'GET', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + }); + const data = await response.json(); + setCartItems(data.data || []); + }; + + fetchCartData(); + }, []); + + const updateQuantity = async (productId: number, newQuantity: number) => { + await fetch(`${process.env.NEXT_PUBLIC_API_URL}/carts`, { + method: 'PATCH', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ productId: productId, quantity: newQuantity }), + }); + + setCartItems((prevItems) => + prevItems.map((item) => + item.productId === productId ? { ...item, quantity: newQuantity, totalPrice: item.productPrice * newQuantity } : item + ) + ); + }; + + // 장바구니 아이템 삭제 요청 + const deleteCartItem = async (productId: number) => { + const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/carts`, { + method: 'DELETE', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ productId: productId }), + }); + + if (response.ok) { + setCartItems((prevItems) => prevItems.filter((item) => item.productId !== productId)); + } else { + console.error('Failed to delete cart item'); + } + }; + + const handleOrderClick = () => { + navigate.push('/order'); + }; + + return ( +
+
+

장바구니

+
+
+ {cartItems.length > 0 ? ( +
+ {cartItems.map((item) => ( +
+ {item.productName} +
+

{item.productName}

+

{`가격: ${item.productPrice.toLocaleString()} 원`}

+
+
+ + {item.quantity} + +

{item.totalPrice.toLocaleString()} 원

+ +
+
+ ))} +
+ ) : ( +

장바구니에 상품이 없습니다.

+ )} +
+ +
+
주문 요약
+
+ {cartItems.map((item) => ( +
+ {item.productName} + {item.quantity}개 +
+ ))} +
+
+
+ 총금액 + {cartItems.reduce((total, item) => total + item.totalPrice, 0).toLocaleString()} 원 +
+ +
+
+
+
+ ); +}; + +export default Page; diff --git a/frontend/src/app/components/Navbar.tsx b/frontend/src/app/components/Navbar.tsx new file mode 100644 index 00000000..e435ffc6 --- /dev/null +++ b/frontend/src/app/components/Navbar.tsx @@ -0,0 +1,93 @@ +// NavBar.tsx +"use client"; +import Link from 'next/link'; +import {useUser} from '@/app/components/UserProvider'; +import {useRouter} from "next/navigation"; + +export default function NavBar() { + const {username, isAdmin, setUsername, setIsAdmin} = useUser(); + const router = useRouter(); + const handleLogout = async () => { + await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/logout`, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + }); + setUsername(''); // Context state update + setIsAdmin(false); // Reset admin status on logout + router.push('/'); + }; + + return ( + + ); +} \ No newline at end of file diff --git a/frontend/src/app/components/OrderHistory.tsx b/frontend/src/app/components/OrderHistory.tsx new file mode 100644 index 00000000..adf4ddf0 --- /dev/null +++ b/frontend/src/app/components/OrderHistory.tsx @@ -0,0 +1,152 @@ +// components/OrderHistory.tsx +"use client"; + +import React, { useEffect, useState } from 'react'; + +type OrderStatus = 'READY' | 'SHIPPED' | 'CANCEL'; + +const ORDER_STATUS_MAP: Record = { + 'READY': '배송 준비중', + 'SHIPPED': '배송중', + 'CANCEL': '배송 취소' +}; + +const STATUS_COLORS: Record = { + 'READY': 'bg-yellow-100 text-yellow-800', + 'SHIPPED': 'bg-blue-100 text-blue-800', + 'CANCEL': 'bg-red-100 text-red-800' +}; + +const OrderCard = ({ order }: { order: Order }) => ( +
+
+
+ 주문번호: {order.id} + + {new Date(order.createAt).toLocaleDateString()} + +
+ + {ORDER_STATUS_MAP[order.status as OrderStatus]} + +
+ +
+ {order.products.map((product, index) => ( +
+
+ {product.imgUrl && ( + {product.name} + )} +
+

{product.name}

+

수량: {product.quantity}개

+
+
+

+ {product.price.toLocaleString()}원 +

+
+ ))} +
+ +
+ 총 결제금액 + + {order.totalPrice.toLocaleString()}원 + +
+
+); + +const OrderGroup = ({ title, orders, statusColor }: { + title: string; + orders: Order[]; + statusColor: string; +}) => { + if (orders.length === 0) return null; + + return ( +
+

{title}

+ {orders.map((order) => ( + + ))} +
+ ); +}; + +export const OrderHistory = () => { + const [orders, setOrders] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchOrders = async () => { + try { + const response = await fetch('http://localhost:8080/api/v1/orders/history', { + credentials: 'include' + }); + const result: OrderResponse = await response.json(); + + if (!response.ok) { + throw new Error(result.message || '주문 내역을 불러오는데 실패했습니다.'); + } + + if (result.success) { + setOrders(result.data); + } + } catch (err) { + setError(err instanceof Error ? err.message : '주문 내역을 불러오는데 실패했습니다.'); + } finally { + setLoading(false); + } + }; + + fetchOrders(); + }, []); + + if (loading) { + return
로딩 중...
; + } + + if (error) { + return
{error}
; + } + + if (orders.length === 0) { + return
주문 내역이 없습니다.
; + } + + const readyOrders = orders.filter(order => order.status === 'READY'); + const shippedOrders = orders.filter(order => order.status === 'SHIPPED'); + const canceledOrders = orders.filter(order => order.status === 'CANCEL'); + + return ( +
+

주문 내역

+ + + + + + +
+ ); +}; \ No newline at end of file diff --git a/frontend/src/app/components/Pagination.tsx b/frontend/src/app/components/Pagination.tsx new file mode 100644 index 00000000..59bc3e24 --- /dev/null +++ b/frontend/src/app/components/Pagination.tsx @@ -0,0 +1,61 @@ +import React from 'react'; + +interface PaginationProps { + page: number; + totalPages: number; + handlePageChange: (page: number) => void; +} + +const Pagination: React.FC = ({ page, totalPages, handlePageChange }) => { + const baseButtonStyles = "px-3 py-2 rounded-md text-sm font-medium transition-colors"; + const activeButtonStyles = "bg-blue-600 text-white hover:bg-blue-700"; + const inactiveButtonStyles = "bg-white text-gray-700 hover:bg-gray-50 border border-gray-300"; + const disabledButtonStyles = "bg-gray-100 text-gray-400 cursor-not-allowed border border-gray-300"; + + return ( +
+
    +
  • + +
  • + + {Array.from({ length: totalPages }, (_, index) => ( +
  • + +
  • + ))} + +
  • + +
  • +
+
+ ); +}; + +export default Pagination; \ No newline at end of file diff --git a/frontend/src/app/components/PasswordChangeForm.tsx b/frontend/src/app/components/PasswordChangeForm.tsx new file mode 100644 index 00000000..e3f50b2d --- /dev/null +++ b/frontend/src/app/components/PasswordChangeForm.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { PasswordInput } from '../components/PasswordInput'; + +interface PasswordFormData { + originalPassword: string; + password: string; + passwordCheck: string; +} + +interface PasswordChangeFormProps { + formData: PasswordFormData; + errors: Partial; + isSubmitting: boolean; + successMessage: string; + onSubmit: (e: React.FormEvent) => void; + onChange: (e: React.ChangeEvent) => void; +} + +export const PasswordChangeForm = ({ + formData, + errors, + isSubmitting, + successMessage, + onSubmit, + onChange, +}: PasswordChangeFormProps) => { + return ( +
+ + + + + + + {successMessage && ( +
+ {successMessage} +
+ )} + + + + ); +}; diff --git a/frontend/src/app/components/PasswordInput.tsx b/frontend/src/app/components/PasswordInput.tsx new file mode 100644 index 00000000..b6bb17da --- /dev/null +++ b/frontend/src/app/components/PasswordInput.tsx @@ -0,0 +1,29 @@ +import React from 'react'; + +interface PasswordInputProps { + name: string; + placeholder: string; + value: string; + onChange: (e: React.ChangeEvent) => void; + error?: string; +} + +export const PasswordInput = ({ name, placeholder, value, onChange, error }: PasswordInputProps) => { + return ( +
+ + {error &&

{error}

} +
+ ); +}; diff --git a/frontend/src/app/components/PasswordResetEmail.tsx b/frontend/src/app/components/PasswordResetEmail.tsx new file mode 100644 index 00000000..cd68d4b5 --- /dev/null +++ b/frontend/src/app/components/PasswordResetEmail.tsx @@ -0,0 +1,42 @@ +// PasswordResetEmail.tsx +"use client"; +import React from 'react'; + +interface PasswordResetEmailProps { + email: string; + isEmailSent: boolean; + onEmailChange: (email: string) => void; + onSendVerification: () => void; +} + +export const PasswordResetEmail: React.FC = ({ + email, + isEmailSent, + onEmailChange, + onSendVerification +}) => { + return ( +
+ onEmailChange(e.target.value)} + className="w-4/5 px-3 py-2 border rounded-md focus:outline-none focus:ring focus:ring-blue-200 text-black" + placeholder="이메일을 입력해주세요!" + disabled={isEmailSent} + /> + +
+ ); +}; \ No newline at end of file diff --git a/frontend/src/app/components/ProductCard.tsx b/frontend/src/app/components/ProductCard.tsx new file mode 100644 index 00000000..836dae74 --- /dev/null +++ b/frontend/src/app/components/ProductCard.tsx @@ -0,0 +1,30 @@ +import Link from 'next/link'; +import Image from 'next/image'; + +interface ProductCardProps { + product: Product; +} + +export const ProductCard = ({ product }: ProductCardProps) => ( +
  • +
    + {product.name} +
    +
    + + {product.name} + +

    + {product.price.toLocaleString()}원 +

    +
    +
  • +); \ No newline at end of file diff --git a/frontend/src/app/components/ProductForm.tsx b/frontend/src/app/components/ProductForm.tsx new file mode 100644 index 00000000..c552696d --- /dev/null +++ b/frontend/src/app/components/ProductForm.tsx @@ -0,0 +1,131 @@ +import { useState, useEffect } from "react"; + +interface ProductFormProps { + initialData?: { + name: string; + content: string; + price: string; // 모든 필드는 string 타입으로 설정 + imgUrl: string; + quantity: string; + }; + onSubmit: (formData: { + name: string; + content: string; + price: number; + imgUrl: string; + quantity: number; + }) => Promise; // Promise를 반환하도록 수정 +} + +export default function ProductForm({ initialData, onSubmit }: ProductFormProps) { + const [formData, setFormData] = useState({ + name: initialData?.name || "", + content: initialData?.content || "", + price: initialData?.price || "", + imgUrl: initialData?.imgUrl || "", + quantity: initialData?.quantity || "", + }); + + const [message, setMessage] = useState(""); + + useEffect(() => { + if (initialData) { + setFormData(initialData); + } + }, [initialData]); + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData((prev) => ({ ...prev, [name]: value })); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + try { + await onSubmit({ + ...formData, + price: Number(formData.price), // 숫자로 변환 + quantity: Number(formData.quantity), // 숫자로 변환 + }); + setMessage("작업이 성공적으로 완료되었습니다."); + setFormData({ name: "", content: "", price: "", imgUrl: "", quantity: "" }); + } catch (error) { + console.error(error); + setMessage("서버와의 통신 중 오류가 발생했습니다."); + } + }; + + return ( +
    +
    +
    + + +
    +
    + +