diff --git a/README.md b/README.md index 8142135..6e32631 100644 --- a/README.md +++ b/README.md @@ -1 +1,106 @@ -# spring-shopping-product \ No newline at end of file +# spring-shopping-product + +## 기능요구사항 + +### 상품 + +- 상품을 추가, 조회, 수정, 삭제할 수 있다. +- 상품 이미지는 파일을 업로드하지 않고 URL로 저장한다. +- 상품 이름은 공백을 포함하여 최대 15자까지 입력할 수 있다. +- 특수문자는 `()`,`[]`,`+`,`-`,`&`,`/`,`_`만 가능하다. +- 상품 이름에는 비속어를 포함할 수 없다. + - [PurgoMalum](https://www.purgomalum.com/)에서 욕설이 포함되어 있는지 확인한다. +- 회원이 위시리스트에 상품을 담으면 상품 하트수가 올라간다. + +### 회원 + +- 회원는 가입, 로그인을 할 수 있다. +- 회원는 이메일, 비밀번호, 이름을 입력하여 가입한다. +- 회원는 이메일과 비밀번호를 입력하여 로그인을 수행하고 토큰을 발급받는다. + - 토큰은 JWT와 SpringSecurity를 사용하여 구현한다. + +### 위시리스트 + +- 발급받은 토큰을 사용하여 위시리스트에 상품을 추가, 조회, 삭제할 수 있다. + +## 용어사전 + +### 상품(Product) + +| 한글명 | 영문명 | 설명 | +|--------|---------------------|---------------------------------| +| 상품 | Product | 쇼핑몰에 등록된 상품. | +| 상품 이름 | Product Name | 상품을 구분하는 이름. 비속어를 사용할 수 없다. | +| 비속어 | Profanity | 불쾌감을 주는 단어로 상품 이름에 사용될 수 없다. | +| 상품 가격 | Product Price | 상품 1개의 가격. 가격은 0원이상이다. | +| 상품 이미지 | Product Image | 상품을 보여주는 이미지. | +| 상품 하트수 | Product Heart Count | 상품의 하트 수. 손님이 하트를 누르면 숫자가 올라간다. | + +### 회원(Member) + +| 한글명 | 영문명 | 설명 | +|-------|-----------------|------------------------------------------------| +| 회원 | Member | 쇼핑몰에 가입된 사람. | +| 이메일 | Email | 회원가입 및 로그인 시 사용되는 아이디. 중복될 수 없다. | +| 비밀번호 | Password | 회원가입 및 로그인 시 사용되는 비밀번호. 암호화되어 저장된다. | +| 회원 이름 | Member Name | 회원을 구분하기 위한 이름. | +| 회원 권한 | Member Priority | 회원별 행위를 제한하기 위한 권한. ex) 사장님(Owner), 손님(Client) | + +#### 사장님(Owner) + +| 한글명 | 영문명 | 설명 | +|-----|-------|--------------------| +| 사장님 | Owner | 쇼핑몰에서 상품을 관리하는 회원. | + +#### 손님(Client) + +| 한글명 | 영문명 | 설명 | +|--------|--------------|----------------------------------| +| 손님 | Client | 쇼핑몰에서 상품을 조회하고 구매를 희망하는 회원. | +| 위시 상품 | Wish Product | 손님이 구매를 희망하는 상품. | +| 하트 누르기 | Wish | 손님이 위시 상품 등록을 위해 상품에 하트를 누르는 행위. | + +## 모델링 + +### 상품 + +#### 프로퍼티 + +- `상품(Product)`은 `상품 이름(Product Name)`, `상품 가격(Product Price)`를 + 필수로 `상품 이미지(Product Image)`를 선택적으로 갖는다. +- `상품 이름(Product Name)`은 공백 포함 최대 15자까지 갖는다. +- `상품 이름(Product Name)`에는 `()`,`[]`,`+`,`-`,`&`,`/`,`_`을 제외한 특수문자를 사용할 수 없다. +- `상품 이름(Product Name)`에는 비속어를 사용할 수 없다. +- `상품 가격(Product Price)`은 0원 이상이다. +#### 행위 + +- `사장님(Owner)`은 `상품(Product)`을 추가할 수 있다. +- `사장님(Owner)`과 `손님(Client)`는 `상품(Product)`을 조회할 수 있다. +- `사장님(Owner)`은 `상품(Product)`을 수정할 수 있다. +- `사장님(Owner)`은 `상품(Product)`을 삭제할 수 있다. + +### 회원(Member) + +#### 프로퍼티 + +- `회원(Member)`은 `이메일(Email)`, `비밀번호(Password)`, `회원 이름(Member Name)`, `회원 권한(Member Priority)`을 필수로 + 갖는다. 그리고 `회원 권한(Member Priority)`에 따라 `위시 상품(Wish Product)`를 갖는다. +- `이메일(Email)`은 다른 회원과 중복될 수 없다. +- `비밀번호(Password)`는 암호화되어야한다. +- `회원 권한(Member Priority)`은 `사장님(Owner)`, `손님(Client)`이 있다. +> `회원 권한(Member Priority)`에 따라 행위가 다르며 이는 아래에서 설명한다. + +#### 행위 + +#### 사장님(Owner) +- `사장님(Owner)`는 `이메일(Email)`, `비밀번호(Password)`, `회원 이름(Member Name)`을 입력하여 가입한다. + - 기본적으로 갖는 권한은 `사장님(Owner)`이다. +- `사장님(Owner)`는 `이메일(Email)`과 `비밀번호(Password)`를 입력하여 로그인한다. + +#### 손님(Client) +- `손님(Client)`는 `이메일(Email)`, `비밀번호(Password)`, `회원 이름(Member Name)`을 입력하여 가입한다. + - 기본적으로 갖는 권한은 `손님(Client)`이다. +- `손님(Client)`는 `이메일(Email)`과 `비밀번호(Password)`를 입력하여 로그인한다. +- `손님(Client)`는 `위시 상품(Wish Product)`등록을 위해 하트를 누를 수 있다. +- `손님(Client)`는 `위시 상품(Wish Product)`을 조회할 수 있다. +- `손님(Client)`는 `위시 상품(Wish Product)`을 삭제할 수 있다. diff --git a/build.gradle.kts b/build.gradle.kts index 3f75395..bbf0d18 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -33,6 +33,17 @@ dependencies { testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") testRuntimeOnly("org.junit.platform:junit-platform-launcher") + // security + implementation("org.springframework.boot:spring-boot-starter-security") + + // jwt + implementation("io.jsonwebtoken:jjwt-api:0.11.5") + implementation("io.jsonwebtoken:jjwt-impl:0.11.5") + implementation("io.jsonwebtoken:jjwt-jackson:0.11.5") + + // lombok + compileOnly("org.projectlombok:lombok") + annotationProcessor("org.projectlombok:lombok") } kotlin { diff --git a/src/main/java/shopping/Application.java b/src/main/java/shopping/Application.java index 9ab85bd..ab61b0f 100644 --- a/src/main/java/shopping/Application.java +++ b/src/main/java/shopping/Application.java @@ -2,9 +2,12 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableAsync; +@EnableAsync @SpringBootApplication public class Application { + public static void main(String[] args) { SpringApplication.run(Application.class, args); } diff --git a/src/main/java/shopping/common/auth/exception/UnAuthenticatedException.java b/src/main/java/shopping/common/auth/exception/UnAuthenticatedException.java new file mode 100644 index 0000000..59fa766 --- /dev/null +++ b/src/main/java/shopping/common/auth/exception/UnAuthenticatedException.java @@ -0,0 +1,10 @@ +package shopping.common.auth.exception; + +import shopping.common.exception.SpringShoppingException; + +public class UnAuthenticatedException extends SpringShoppingException { + + public UnAuthenticatedException(final String message) { + super(message); + } +} diff --git a/src/main/java/shopping/common/auth/filter/JwtAuthenticationFilter.java b/src/main/java/shopping/common/auth/filter/JwtAuthenticationFilter.java new file mode 100644 index 0000000..d6340dc --- /dev/null +++ b/src/main/java/shopping/common/auth/filter/JwtAuthenticationFilter.java @@ -0,0 +1,48 @@ +package shopping.common.auth.filter; + +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.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; +import shopping.common.auth.token.JwtProvider; + +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final String AUTHORIZATION_TYPE = "Bearer"; + + private final JwtProvider jwtTokenProvider; + + @Override + public void doFilterInternal(final HttpServletRequest request, + final HttpServletResponse response, + final FilterChain filterChain) throws IOException, ServletException { + // 1. Request Header에서 JWT 토큰 추출 + String token = resolveToken(request); + String requestURI = request.getRequestURI(); + + // 2. validateToken으로 토큰 유효성 검사 + if (token != null && jwtTokenProvider.validateToken(token)) { + // 토큰이 유효할 경우 토큰에서 Authentication 객체를 가지고 와서 SecurityContext에 저장 + Authentication authentication = jwtTokenProvider.getAuthentication(token); + SecurityContextHolder.getContext().setAuthentication(authentication); + logger.debug("Security Context에 인증 정보를 저장했습니다. uri: " + requestURI); + } + filterChain.doFilter(request, response); + } + + private String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader(AUTHORIZATION_HEADER); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(AUTHORIZATION_TYPE)) { + return bearerToken.substring(7); + } + return null; + } +} diff --git a/src/main/java/shopping/common/auth/handler/JwtAccessDeniedHandler.java b/src/main/java/shopping/common/auth/handler/JwtAccessDeniedHandler.java new file mode 100644 index 0000000..97304f8 --- /dev/null +++ b/src/main/java/shopping/common/auth/handler/JwtAccessDeniedHandler.java @@ -0,0 +1,20 @@ +package shopping.common.auth.handler; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +@Component +public class JwtAccessDeniedHandler implements AccessDeniedHandler { + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, + AccessDeniedException accessDeniedException) throws IOException, ServletException { + //필요한 권한이 없이 접근하려 할때 403 + response.sendError(HttpServletResponse.SC_FORBIDDEN); + } +} \ No newline at end of file diff --git a/src/main/java/shopping/common/auth/handler/JwtAuthenticationEntryPoint.java b/src/main/java/shopping/common/auth/handler/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..7b3d4d6 --- /dev/null +++ b/src/main/java/shopping/common/auth/handler/JwtAuthenticationEntryPoint.java @@ -0,0 +1,20 @@ +package shopping.common.auth.handler; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +@Component +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + @Override + public void commence(HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException) throws IOException { + // 유효한 자격증명을 제공하지 않고 접근하려 할때 401 + response.sendError(HttpServletResponse.SC_UNAUTHORIZED); + } +} \ No newline at end of file diff --git a/src/main/java/shopping/common/auth/resolver/LoginClient.java b/src/main/java/shopping/common/auth/resolver/LoginClient.java new file mode 100644 index 0000000..65893b5 --- /dev/null +++ b/src/main/java/shopping/common/auth/resolver/LoginClient.java @@ -0,0 +1,12 @@ +package shopping.common.auth.resolver; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface LoginClient { + +} diff --git a/src/main/java/shopping/common/auth/resolver/LoginClientArgumentResolver.java b/src/main/java/shopping/common/auth/resolver/LoginClientArgumentResolver.java new file mode 100644 index 0000000..53e2607 --- /dev/null +++ b/src/main/java/shopping/common/auth/resolver/LoginClientArgumentResolver.java @@ -0,0 +1,46 @@ +package shopping.common.auth.resolver; + +import lombok.RequiredArgsConstructor; +import org.springframework.core.MethodParameter; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; +import shopping.common.auth.exception.UnAuthenticatedException; +import shopping.member.client.domain.Client; +import shopping.member.common.application.AuthService; +import shopping.member.common.domain.Member; +import shopping.member.common.domain.MemberRole; + +@Component +@RequiredArgsConstructor +public class LoginClientArgumentResolver implements HandlerMethodArgumentResolver { + + private final AuthService authService; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(LoginClient.class) + && parameter.getParameterType().equals(Client.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + try { + return getClient(); + } catch (RuntimeException e) { + throw new UnAuthenticatedException("인증에 실패하였습니다."); + } + } + + private Member getClient() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + UserDetails userDetails = (UserDetails) authentication.getPrincipal(); + return authService.findMember(userDetails.getUsername(), MemberRole.CLIENT); + } +} diff --git a/src/main/java/shopping/common/auth/token/JwtGenerator.java b/src/main/java/shopping/common/auth/token/JwtGenerator.java new file mode 100644 index 0000000..98ba319 --- /dev/null +++ b/src/main/java/shopping/common/auth/token/JwtGenerator.java @@ -0,0 +1,35 @@ +package shopping.common.auth.token; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import java.security.Key; +import java.util.Date; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class JwtGenerator implements TokenGenerator { + + private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 60; + private final Key key; + + public JwtGenerator(@Value("${jwt.secret}") String secretKey) { + byte[] keyBytes = Decoders.BASE64.decode(secretKey); + this.key = Keys.hmacShaKeyFor(keyBytes); + } + + public String generate(String email, String role) { + + long now = (new Date()).getTime(); + + // Access Token 생성 + return Jwts.builder() + .setSubject(email) + .claim("auth", role) + .setExpiration(new Date(now + ACCESS_TOKEN_EXPIRE_TIME)) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } +} diff --git a/src/main/java/shopping/common/auth/token/JwtProvider.java b/src/main/java/shopping/common/auth/token/JwtProvider.java new file mode 100644 index 0000000..63ea4aa --- /dev/null +++ b/src/main/java/shopping/common/auth/token/JwtProvider.java @@ -0,0 +1,80 @@ +package shopping.common.auth.token; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import java.security.Key; +import java.util.Arrays; +import java.util.Collection; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class JwtProvider { + + private final Key key; + + public JwtProvider(@Value("${jwt.secret}") String secretKey) { + byte[] keyBytes = Decoders.BASE64.decode(secretKey); + this.key = Keys.hmacShaKeyFor(keyBytes); + } + + public Authentication getAuthentication(String accessToken) { + // 토큰 복호화 + Claims claims = parseClaims(accessToken); + + if (claims.get("auth") == null) { + throw new RuntimeException("권한 정보가 없는 토큰입니다."); + } + + // 클레임에서 권한 정보 가져오기 + Collection authorities = Arrays.stream( + claims.get("auth").toString().split(",")) + .map(SimpleGrantedAuthority::new) + .toList(); + + // UserDetails 객체를 만들어서 Authentication 리턴 + UserDetails userDetails = new User(claims.getSubject(), "", authorities); + return new UsernamePasswordAuthenticationToken(userDetails, "", authorities); + } + + public boolean validateToken(String token) { + try { + Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); + return true; + } catch (SecurityException | MalformedJwtException e) { + log.info("Invalid JWT Token", e); + } catch (ExpiredJwtException e) { + log.info("Expired JWT Token", e); + } catch (UnsupportedJwtException e) { + log.info("Unsupported JWT Token", e); + } catch (IllegalArgumentException e) { + log.info("JWT claims string is empty.", e); + } + return false; + } + + private Claims parseClaims(String accessToken) { + try { + return Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(accessToken) + .getBody(); + } catch (ExpiredJwtException e) { + return e.getClaims(); + } + } +} diff --git a/src/main/java/shopping/common/auth/token/TokenGenerator.java b/src/main/java/shopping/common/auth/token/TokenGenerator.java new file mode 100644 index 0000000..287fa61 --- /dev/null +++ b/src/main/java/shopping/common/auth/token/TokenGenerator.java @@ -0,0 +1,7 @@ +package shopping.common.auth.token; + +@FunctionalInterface +public interface TokenGenerator { + + String generate(String email, String role); +} diff --git a/src/main/java/shopping/common/config/RestClientConfig.java b/src/main/java/shopping/common/config/RestClientConfig.java new file mode 100644 index 0000000..ccf485a --- /dev/null +++ b/src/main/java/shopping/common/config/RestClientConfig.java @@ -0,0 +1,14 @@ +package shopping.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestClient; + +@Configuration +public class RestClientConfig { + + @Bean + public RestClient restClient() { + return RestClient.create(); + } +} diff --git a/src/main/java/shopping/common/config/SecurityConfig.java b/src/main/java/shopping/common/config/SecurityConfig.java new file mode 100644 index 0000000..31d1f30 --- /dev/null +++ b/src/main/java/shopping/common/config/SecurityConfig.java @@ -0,0 +1,68 @@ +package shopping.common.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +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.annotation.web.configurers.HeadersConfigurer.FrameOptionsConfig; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsUtils; +import shopping.common.auth.filter.JwtAuthenticationFilter; +import shopping.common.auth.handler.JwtAccessDeniedHandler; +import shopping.common.auth.handler.JwtAuthenticationEntryPoint; +import shopping.common.auth.token.JwtProvider; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtProvider jwtTokenProvider; + private final JwtAccessDeniedHandler jwtAccessDeniedHandler; + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + return http + .cors(Customizer.withDefaults()) + .csrf(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .headers(headers -> headers.frameOptions( + FrameOptionsConfig::disable)) + .sessionManagement( + managementConfigurer -> managementConfigurer.sessionCreationPolicy( + SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests((authorizeRequests) -> { + authorizeRequests.requestMatchers(CorsUtils::isPreFlightRequest).permitAll(); + authorizeRequests.requestMatchers( + "/h2-console/**" + ).permitAll(); + authorizeRequests.requestMatchers( + "/api/client/signup", + "/api/client/login", + "/api/owner/signup", + "/api/owner/login" + ).permitAll(); + authorizeRequests.anyRequest().authenticated(); + }) + .exceptionHandling(exceptionHandling -> + exceptionHandling + .accessDeniedHandler(jwtAccessDeniedHandler) + .authenticationEntryPoint(jwtAuthenticationEntryPoint)) + .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), + UsernamePasswordAuthenticationFilter.class) + .build(); + } + + @Bean + public BCryptPasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} \ No newline at end of file diff --git a/src/main/java/shopping/common/config/WebConfig.java b/src/main/java/shopping/common/config/WebConfig.java new file mode 100644 index 0000000..23910d8 --- /dev/null +++ b/src/main/java/shopping/common/config/WebConfig.java @@ -0,0 +1,20 @@ +package shopping.common.config; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import shopping.common.auth.resolver.LoginClientArgumentResolver; + +@Configuration +@RequiredArgsConstructor +public class WebConfig implements WebMvcConfigurer { + + private final LoginClientArgumentResolver loginClientArgumentResolver; + + @Override + public void addArgumentResolvers(final List resolvers) { + resolvers.add(loginClientArgumentResolver); + } +} diff --git a/src/main/java/shopping/common/domain/AuditableBaseEntity.java b/src/main/java/shopping/common/domain/AuditableBaseEntity.java new file mode 100644 index 0000000..41cb4b7 --- /dev/null +++ b/src/main/java/shopping/common/domain/AuditableBaseEntity.java @@ -0,0 +1,20 @@ +package shopping.common.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import org.springframework.data.annotation.CreatedBy; +import org.springframework.data.annotation.LastModifiedBy; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public class AuditableBaseEntity extends BaseEntity { + + @CreatedBy + @Column(updatable = false) + private String createdBy; + + @LastModifiedBy + private String updatedBy; +} diff --git a/src/main/java/shopping/common/domain/BaseEntity.java b/src/main/java/shopping/common/domain/BaseEntity.java new file mode 100644 index 0000000..aae42bd --- /dev/null +++ b/src/main/java/shopping/common/domain/BaseEntity.java @@ -0,0 +1,18 @@ +package shopping.common.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.MappedSuperclass; +import java.time.LocalDateTime; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; + +@MappedSuperclass +public abstract class BaseEntity { + + @CreatedDate + @Column(updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime updatedAt; +} diff --git a/src/main/java/shopping/common/exception/SpringShoppingException.java b/src/main/java/shopping/common/exception/SpringShoppingException.java new file mode 100644 index 0000000..3da76c8 --- /dev/null +++ b/src/main/java/shopping/common/exception/SpringShoppingException.java @@ -0,0 +1,8 @@ +package shopping.common.exception; + +public class SpringShoppingException extends RuntimeException { + + public SpringShoppingException(final String message) { + super(message); + } +} diff --git a/src/main/java/shopping/member/client/applicaton/ClientLoginService.java b/src/main/java/shopping/member/client/applicaton/ClientLoginService.java new file mode 100644 index 0000000..5aab2c5 --- /dev/null +++ b/src/main/java/shopping/member/client/applicaton/ClientLoginService.java @@ -0,0 +1,38 @@ +package shopping.member.client.applicaton; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import shopping.member.client.applicaton.dto.ClientCreateRequest; +import shopping.member.client.applicaton.dto.ClientLoginRequest; +import shopping.member.client.applicaton.dto.ClientLoginResponse; +import shopping.member.client.domain.Client; +import shopping.member.client.domain.ClientRepository; +import shopping.member.common.application.AuthService; +import shopping.member.common.domain.MemberRole; +import shopping.member.common.domain.Password; + +@Service +@RequiredArgsConstructor +public class ClientLoginService { + + private final AuthService authService; + private final ClientRepository clientRepository; + + public void signUp(final ClientCreateRequest request) { + authService.validateEmail(request.email()); + + final Password password = authService.encodePassword(request.password()); + final Client client = new Client( + request.email(), + password, + request.memberName() + ); + clientRepository.save(client); + } + + public ClientLoginResponse login(final ClientLoginRequest request) { + final String accessToken = authService.login(request.email(), request.password(), + MemberRole.CLIENT); + return new ClientLoginResponse(accessToken); + } +} diff --git a/src/main/java/shopping/member/client/applicaton/ClientWishService.java b/src/main/java/shopping/member/client/applicaton/ClientWishService.java new file mode 100644 index 0000000..1acaa52 --- /dev/null +++ b/src/main/java/shopping/member/client/applicaton/ClientWishService.java @@ -0,0 +1,36 @@ +package shopping.member.client.applicaton; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import shopping.member.client.applicaton.dto.WishProductResponse; +import shopping.member.client.domain.Client; +import shopping.product.application.event.UnWishEvent; +import shopping.product.application.event.WishEvent; + +@Service +@RequiredArgsConstructor +public class ClientWishService { + + private final WishProductMapper wishProductMapper; + private final ApplicationEventPublisher eventPublisher; + + @Transactional + public void wish(Long productId, Client client) { + wishProductMapper.validateProduct(productId); + client.wish(productId); + eventPublisher.publishEvent(new WishEvent(productId)); + } + + @Transactional + public void unWish(Long productId, Client client) { + client.unWish(productId); + eventPublisher.publishEvent(new UnWishEvent(productId)); + } + + public List findAll(Client client) { + return wishProductMapper.createResponse(client.productIds()); + } +} diff --git a/src/main/java/shopping/member/client/applicaton/WishProductMapper.java b/src/main/java/shopping/member/client/applicaton/WishProductMapper.java new file mode 100644 index 0000000..4b79ba3 --- /dev/null +++ b/src/main/java/shopping/member/client/applicaton/WishProductMapper.java @@ -0,0 +1,30 @@ +package shopping.member.client.applicaton; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import shopping.member.client.applicaton.dto.WishProductResponse; +import shopping.product.application.ProductService; + +@Service +@RequiredArgsConstructor +public class WishProductMapper { + + private final ProductService productService; + + public void validateProduct(Long productId) { + productService.findProduct(productId); + } + + public List createResponse(List productIds) { + return productService.findProducts(productIds) + .stream() + .map(product -> new WishProductResponse( + product.getId(), + product.getName(), + product.getPrice(), + product.getImage()) + ) + .toList(); + } +} diff --git a/src/main/java/shopping/member/client/applicaton/dto/ClientCreateRequest.java b/src/main/java/shopping/member/client/applicaton/dto/ClientCreateRequest.java new file mode 100644 index 0000000..e87edcf --- /dev/null +++ b/src/main/java/shopping/member/client/applicaton/dto/ClientCreateRequest.java @@ -0,0 +1,14 @@ +package shopping.member.client.applicaton.dto; + +import jakarta.validation.constraints.NotBlank; + +public record ClientCreateRequest( + @NotBlank + String email, + @NotBlank + String password, + @NotBlank + String memberName +) { + +} diff --git a/src/main/java/shopping/member/client/applicaton/dto/ClientLoginRequest.java b/src/main/java/shopping/member/client/applicaton/dto/ClientLoginRequest.java new file mode 100644 index 0000000..bdf7ed2 --- /dev/null +++ b/src/main/java/shopping/member/client/applicaton/dto/ClientLoginRequest.java @@ -0,0 +1,12 @@ +package shopping.member.client.applicaton.dto; + +import jakarta.validation.constraints.NotBlank; + +public record ClientLoginRequest( + @NotBlank + String email, + @NotBlank + String password +) { + +} diff --git a/src/main/java/shopping/member/client/applicaton/dto/ClientLoginResponse.java b/src/main/java/shopping/member/client/applicaton/dto/ClientLoginResponse.java new file mode 100644 index 0000000..c26e9df --- /dev/null +++ b/src/main/java/shopping/member/client/applicaton/dto/ClientLoginResponse.java @@ -0,0 +1,5 @@ +package shopping.member.client.applicaton.dto; + +public record ClientLoginResponse(String accessToken) { + +} diff --git a/src/main/java/shopping/member/client/applicaton/dto/WishProductResponse.java b/src/main/java/shopping/member/client/applicaton/dto/WishProductResponse.java new file mode 100644 index 0000000..48660ca --- /dev/null +++ b/src/main/java/shopping/member/client/applicaton/dto/WishProductResponse.java @@ -0,0 +1,12 @@ +package shopping.member.client.applicaton.dto; + +import java.math.BigDecimal; + +public record WishProductResponse( + Long id, + String name, + BigDecimal bigDecimal, + String image +) { + +} diff --git a/src/main/java/shopping/member/client/domain/Client.java b/src/main/java/shopping/member/client/domain/Client.java new file mode 100644 index 0000000..401428b --- /dev/null +++ b/src/main/java/shopping/member/client/domain/Client.java @@ -0,0 +1,36 @@ +package shopping.member.client.domain; + +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import java.util.List; +import lombok.Getter; +import shopping.member.common.domain.Member; +import shopping.member.common.domain.MemberRole; +import shopping.member.common.domain.Password; + +@Entity +public class Client extends Member { + + @Embedded + @Getter + private WishProducts wishProducts = new WishProducts(); + + protected Client() { + } + + public Client(final String email, final Password password, final String memberName) { + super(email, password, memberName, MemberRole.CLIENT); + } + + public void wish(final Long productId) { + wishProducts.wish(productId); + } + + public void unWish(final Long productId) { + wishProducts.unWish(productId); + } + + public List productIds() { + return wishProducts.productIds(); + } +} diff --git a/src/main/java/shopping/member/client/domain/ClientRepository.java b/src/main/java/shopping/member/client/domain/ClientRepository.java new file mode 100644 index 0000000..0eb50f3 --- /dev/null +++ b/src/main/java/shopping/member/client/domain/ClientRepository.java @@ -0,0 +1,6 @@ +package shopping.member.client.domain; + +public interface ClientRepository { + + Client save(Client client); +} diff --git a/src/main/java/shopping/member/client/domain/WishProduct.java b/src/main/java/shopping/member/client/domain/WishProduct.java new file mode 100644 index 0000000..6dc5917 --- /dev/null +++ b/src/main/java/shopping/member/client/domain/WishProduct.java @@ -0,0 +1,33 @@ +package shopping.member.client.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import shopping.common.domain.BaseEntity; + +@Entity +@Table(name = "wish_products") +public class WishProduct extends BaseEntity { + + @Id + @GeneratedValue + @Column(name = "wish_product_id", nullable = false) + private Long id; + + @Getter + private Long productId; + + protected WishProduct() { + } + + public WishProduct(final Long productId) { + this.productId = productId; + } + + public boolean isSameProduct(Long productId) { + return this.productId.equals(productId); + } +} diff --git a/src/main/java/shopping/member/client/domain/WishProducts.java b/src/main/java/shopping/member/client/domain/WishProducts.java new file mode 100644 index 0000000..e10d640 --- /dev/null +++ b/src/main/java/shopping/member/client/domain/WishProducts.java @@ -0,0 +1,50 @@ +package shopping.member.client.domain; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Embeddable; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToMany; +import java.util.ArrayList; +import java.util.List; +import shopping.member.client.exception.DuplicateWishProductException; +import shopping.member.client.exception.NotFoundWishProductException; + +@Embeddable +public class WishProducts { + + @OneToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE}) + @JoinColumn(name = "email", nullable = false) + private List wishProducts = new ArrayList<>(); + + protected WishProducts() { + } + + public void wish(Long productId) { + validateAddWishItem(productId); + this.wishProducts.add(new WishProduct(productId)); + } + + private void validateAddWishItem(final Long productId) { + final boolean match = this.wishProducts.stream() + .anyMatch(wishProduct -> wishProduct.isSameProduct(productId)); + if (match) { + throw new DuplicateWishProductException( + "중복되는 상품에 하트를 누를 수 없습니다. " + productId); + } + } + + public void unWish(Long productId) { + final boolean removed = this.wishProducts.removeIf( + wishProduct -> wishProduct.isSameProduct(productId)); + if (!removed) { + throw new NotFoundWishProductException( + "해당 위시상품을 찾을 수 없습니다. " + productId); + } + } + + public List productIds() { + return wishProducts.stream() + .map(WishProduct::getProductId) + .toList(); + } +} diff --git a/src/main/java/shopping/member/client/exception/DuplicateWishProductException.java b/src/main/java/shopping/member/client/exception/DuplicateWishProductException.java new file mode 100644 index 0000000..9f1de5e --- /dev/null +++ b/src/main/java/shopping/member/client/exception/DuplicateWishProductException.java @@ -0,0 +1,10 @@ +package shopping.member.client.exception; + +import shopping.common.exception.SpringShoppingException; + +public class DuplicateWishProductException extends SpringShoppingException { + + public DuplicateWishProductException(final String message) { + super(message); + } +} diff --git a/src/main/java/shopping/member/client/exception/NotFoundWishProductException.java b/src/main/java/shopping/member/client/exception/NotFoundWishProductException.java new file mode 100644 index 0000000..aa39dfd --- /dev/null +++ b/src/main/java/shopping/member/client/exception/NotFoundWishProductException.java @@ -0,0 +1,10 @@ +package shopping.member.client.exception; + +import shopping.common.exception.SpringShoppingException; + +public class NotFoundWishProductException extends SpringShoppingException { + + public NotFoundWishProductException(final String message) { + super(message); + } +} diff --git a/src/main/java/shopping/member/client/infra/JPAClientRepository.java b/src/main/java/shopping/member/client/infra/JPAClientRepository.java new file mode 100644 index 0000000..4b1baf5 --- /dev/null +++ b/src/main/java/shopping/member/client/infra/JPAClientRepository.java @@ -0,0 +1,10 @@ +package shopping.member.client.infra; + +import org.springframework.data.jpa.repository.JpaRepository; +import shopping.member.client.domain.Client; +import shopping.member.client.domain.ClientRepository; + +public interface JPAClientRepository extends ClientRepository, JpaRepository { + + +} diff --git a/src/main/java/shopping/member/client/ui/ClientController.java b/src/main/java/shopping/member/client/ui/ClientController.java new file mode 100644 index 0000000..fae3c0d --- /dev/null +++ b/src/main/java/shopping/member/client/ui/ClientController.java @@ -0,0 +1,64 @@ +package shopping.member.client.ui; + +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import shopping.common.auth.resolver.LoginClient; +import shopping.member.client.applicaton.ClientLoginService; +import shopping.member.client.applicaton.ClientWishService; +import shopping.member.client.applicaton.dto.ClientCreateRequest; +import shopping.member.client.applicaton.dto.ClientLoginRequest; +import shopping.member.client.applicaton.dto.ClientLoginResponse; +import shopping.member.client.applicaton.dto.WishProductResponse; +import shopping.member.client.domain.Client; + +@RequestMapping("/api/client") +@RestController +@RequiredArgsConstructor +public class ClientController { + + private final ClientLoginService clientLoginService; + private final ClientWishService clientWishService; + + @PostMapping("/signup") + public ResponseEntity signUp(@RequestBody @Valid final ClientCreateRequest request) { + clientLoginService.signUp(request); + return ResponseEntity.ok().build(); + } + + @PostMapping("/login") + public ResponseEntity login( + @RequestBody @Valid final ClientLoginRequest request) { + final ClientLoginResponse response = clientLoginService.login(request); + return ResponseEntity.ok(response); + } + + + @PutMapping("/wish/{productId}") + public ResponseEntity wish(@PathVariable final Long productId, + @LoginClient Client client) { + clientWishService.wish(productId, client); + return ResponseEntity.ok().build(); + } + + @PutMapping("/un-wish/{productId}") + public ResponseEntity unWish(@PathVariable final Long productId, + @LoginClient Client client) { + clientWishService.unWish(productId, client); + return ResponseEntity.ok().build(); + } + + @GetMapping("/wish") + public ResponseEntity> findAll(@LoginClient Client client) { + final List responses = clientWishService.findAll(client); + return ResponseEntity.ok().body(responses); + } +} diff --git a/src/main/java/shopping/member/common/application/AuthService.java b/src/main/java/shopping/member/common/application/AuthService.java new file mode 100644 index 0000000..9aee42f --- /dev/null +++ b/src/main/java/shopping/member/common/application/AuthService.java @@ -0,0 +1,56 @@ +package shopping.member.common.application; + +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import shopping.common.auth.token.TokenGenerator; +import shopping.member.common.domain.Member; +import shopping.member.common.domain.MemberRepository; +import shopping.member.common.domain.MemberRole; +import shopping.member.common.domain.Password; +import shopping.member.common.domain.PasswordEncoder; +import shopping.member.common.exception.InvalidEmailException; +import shopping.member.common.exception.InvalidMemberException; +import shopping.member.common.exception.InvalidPasswordException; +import shopping.member.common.exception.NotFoundMemberException; + +@Service +@RequiredArgsConstructor +public class AuthService { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + private final TokenGenerator tokenGenerator; + + public void validateEmail(final String email) { + final Optional existMember = memberRepository.findByEmail(email); + if (existMember.isPresent()) { + throw new InvalidEmailException("이미 존재하는 이메일 입니다. " + email); + } + } + + public Password encodePassword(final String rawPassword) { + return new Password(rawPassword, passwordEncoder); + } + + public String login(final String email, final String rawPassword, final MemberRole role) { + final Member member = findMember(email, role); + + if (!member.isValidPassword(rawPassword, passwordEncoder)) { + throw new InvalidPasswordException("비밀번호가 틀렸습니다."); + } + + return tokenGenerator.generate(member.getEmail(), member.getMemberRole()); + } + + public Member findMember(String email, MemberRole role) { + final Member member = memberRepository.findByEmail(email) + .orElseThrow(() -> new NotFoundMemberException("찾을 수 없는 사용자입니다. " + email)); + + if (!member.isValidRole(role)) { + throw new InvalidMemberException("권한이 없는 회원입니다."); + } + + return member; + } +} diff --git a/src/main/java/shopping/member/common/domain/Member.java b/src/main/java/shopping/member/common/domain/Member.java new file mode 100644 index 0000000..ac3dfc6 --- /dev/null +++ b/src/main/java/shopping/member/common/domain/Member.java @@ -0,0 +1,60 @@ +package shopping.member.common.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.DiscriminatorColumn; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Inheritance; +import jakarta.persistence.InheritanceType; +import jakarta.persistence.Table; +import lombok.Getter; +import shopping.common.domain.BaseEntity; + +@Entity +@Table(name = "members") +@DiscriminatorColumn(name = "member_role") +@Inheritance(strategy = InheritanceType.SINGLE_TABLE) +public abstract class Member extends BaseEntity { + + @Id + @Column(name = "email", nullable = false) + @Getter + private String email; + + @Embedded + private Password password; + + @Column(name = "member_name", nullable = false) + private String memberName; + + @Column(name = "member_role", insertable = false, updatable = false) + @Enumerated(EnumType.STRING) + private MemberRole memberRole; + + protected Member() { + } + + protected Member(final String email, final Password password, final String memberName, + final MemberRole role) { + this.email = email; + this.password = password; + this.memberName = memberName; + this.memberRole = role; + } + + public boolean isValidPassword(final String rawPassword, + final PasswordEncoder passwordEncoder) { + return password.isMatch(rawPassword, passwordEncoder); + } + + public String getMemberRole() { + return memberRole.name(); + } + + public boolean isValidRole(MemberRole role) { + return this.memberRole.equals(role); + } +} diff --git a/src/main/java/shopping/member/common/domain/MemberRepository.java b/src/main/java/shopping/member/common/domain/MemberRepository.java new file mode 100644 index 0000000..0c5636b --- /dev/null +++ b/src/main/java/shopping/member/common/domain/MemberRepository.java @@ -0,0 +1,8 @@ +package shopping.member.common.domain; + +import java.util.Optional; + +public interface MemberRepository { + + Optional findByEmail(String email); +} diff --git a/src/main/java/shopping/member/common/domain/MemberRole.java b/src/main/java/shopping/member/common/domain/MemberRole.java new file mode 100644 index 0000000..747ca5c --- /dev/null +++ b/src/main/java/shopping/member/common/domain/MemberRole.java @@ -0,0 +1,5 @@ +package shopping.member.common.domain; + +public enum MemberRole { + CLIENT, OWNER +} diff --git a/src/main/java/shopping/member/common/domain/Password.java b/src/main/java/shopping/member/common/domain/Password.java new file mode 100644 index 0000000..65d0d27 --- /dev/null +++ b/src/main/java/shopping/member/common/domain/Password.java @@ -0,0 +1,32 @@ +package shopping.member.common.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; + +@Embeddable +public class Password { + + @Column(name = "password", nullable = false) + private String password; + + protected Password() { + } + + public Password(final String rawPassword, final PasswordEncoder encoder) { + this.password = encoder.encode(rawPassword); + } + + public boolean isMatch(final String rawPassword, final PasswordEncoder encoder) { + return encoder.matches(rawPassword, password); + } + + @Override + public int hashCode() { + return super.hashCode(); + } + + @Override + public boolean equals(Object obj) { + return super.equals(obj); + } +} diff --git a/src/main/java/shopping/member/common/domain/PasswordEncoder.java b/src/main/java/shopping/member/common/domain/PasswordEncoder.java new file mode 100644 index 0000000..0bc43af --- /dev/null +++ b/src/main/java/shopping/member/common/domain/PasswordEncoder.java @@ -0,0 +1,8 @@ +package shopping.member.common.domain; + +public interface PasswordEncoder { + + String encode(final String rawPassword); + + boolean matches(final String rawPassword, final String password); +} diff --git a/src/main/java/shopping/member/common/exception/InvalidEmailException.java b/src/main/java/shopping/member/common/exception/InvalidEmailException.java new file mode 100644 index 0000000..8bf4524 --- /dev/null +++ b/src/main/java/shopping/member/common/exception/InvalidEmailException.java @@ -0,0 +1,10 @@ +package shopping.member.common.exception; + +import shopping.common.exception.SpringShoppingException; + +public class InvalidEmailException extends SpringShoppingException { + + public InvalidEmailException(final String message) { + super(message); + } +} diff --git a/src/main/java/shopping/member/common/exception/InvalidMemberException.java b/src/main/java/shopping/member/common/exception/InvalidMemberException.java new file mode 100644 index 0000000..2eb7fec --- /dev/null +++ b/src/main/java/shopping/member/common/exception/InvalidMemberException.java @@ -0,0 +1,10 @@ +package shopping.member.common.exception; + +import shopping.common.exception.SpringShoppingException; + +public class InvalidMemberException extends SpringShoppingException { + + public InvalidMemberException(final String message) { + super(message); + } +} diff --git a/src/main/java/shopping/member/common/exception/InvalidPasswordException.java b/src/main/java/shopping/member/common/exception/InvalidPasswordException.java new file mode 100644 index 0000000..216235a --- /dev/null +++ b/src/main/java/shopping/member/common/exception/InvalidPasswordException.java @@ -0,0 +1,10 @@ +package shopping.member.common.exception; + +import shopping.common.exception.SpringShoppingException; + +public class InvalidPasswordException extends SpringShoppingException { + + public InvalidPasswordException(final String message) { + super(message); + } +} diff --git a/src/main/java/shopping/member/common/exception/NotFoundMemberException.java b/src/main/java/shopping/member/common/exception/NotFoundMemberException.java new file mode 100644 index 0000000..64ec7dd --- /dev/null +++ b/src/main/java/shopping/member/common/exception/NotFoundMemberException.java @@ -0,0 +1,10 @@ +package shopping.member.common.exception; + +import shopping.common.exception.SpringShoppingException; + +public class NotFoundMemberException extends SpringShoppingException { + + public NotFoundMemberException(final String message) { + super(message); + } +} diff --git a/src/main/java/shopping/member/common/infra/DefaultPasswordEncoder.java b/src/main/java/shopping/member/common/infra/DefaultPasswordEncoder.java new file mode 100644 index 0000000..716753b --- /dev/null +++ b/src/main/java/shopping/member/common/infra/DefaultPasswordEncoder.java @@ -0,0 +1,23 @@ +package shopping.member.common.infra; + +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Component; +import shopping.member.common.domain.PasswordEncoder; + +@Component +@RequiredArgsConstructor +public class DefaultPasswordEncoder implements PasswordEncoder { + + private final BCryptPasswordEncoder passwordEncoder; + + @Override + public String encode(final String rawPassword) { + return passwordEncoder.encode(rawPassword); + } + + @Override + public boolean matches(final String rawPassword, final String password) { + return passwordEncoder.matches(rawPassword, password); + } +} diff --git a/src/main/java/shopping/member/common/infra/JPAMemberRepository.java b/src/main/java/shopping/member/common/infra/JPAMemberRepository.java new file mode 100644 index 0000000..9332b3e --- /dev/null +++ b/src/main/java/shopping/member/common/infra/JPAMemberRepository.java @@ -0,0 +1,9 @@ +package shopping.member.common.infra; + +import org.springframework.data.jpa.repository.JpaRepository; +import shopping.member.common.domain.Member; +import shopping.member.common.domain.MemberRepository; + +public interface JPAMemberRepository extends MemberRepository, JpaRepository { + +} diff --git a/src/main/java/shopping/member/owner/application/OwnerLoginService.java b/src/main/java/shopping/member/owner/application/OwnerLoginService.java new file mode 100644 index 0000000..2d9e7cd --- /dev/null +++ b/src/main/java/shopping/member/owner/application/OwnerLoginService.java @@ -0,0 +1,38 @@ +package shopping.member.owner.application; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import shopping.member.common.application.AuthService; +import shopping.member.common.domain.MemberRole; +import shopping.member.common.domain.Password; +import shopping.member.owner.application.dto.OwnerCreateRequest; +import shopping.member.owner.application.dto.OwnerLoginRequest; +import shopping.member.owner.application.dto.OwnerLoginResponse; +import shopping.member.owner.domain.Owner; +import shopping.member.owner.domain.OwnerRepository; + +@Service +@RequiredArgsConstructor +public class OwnerLoginService { + + private final AuthService authService; + private final OwnerRepository ownerRepository; + + public void signUp(final OwnerCreateRequest request) { + authService.validateEmail(request.email()); + + final Password password = authService.encodePassword(request.password()); + final Owner owner = new Owner( + request.email(), + password, + request.memberName() + ); + ownerRepository.save(owner); + } + + public OwnerLoginResponse login(final OwnerLoginRequest request) { + final String accessToken = authService.login(request.email(), request.password(), + MemberRole.OWNER); + return new OwnerLoginResponse(accessToken); + } +} diff --git a/src/main/java/shopping/member/owner/application/dto/OwnerCreateRequest.java b/src/main/java/shopping/member/owner/application/dto/OwnerCreateRequest.java new file mode 100644 index 0000000..bdd0dd3 --- /dev/null +++ b/src/main/java/shopping/member/owner/application/dto/OwnerCreateRequest.java @@ -0,0 +1,14 @@ +package shopping.member.owner.application.dto; + +import jakarta.validation.constraints.NotBlank; + +public record OwnerCreateRequest( + @NotBlank + String email, + @NotBlank + String password, + @NotBlank + String memberName +) { + +} diff --git a/src/main/java/shopping/member/owner/application/dto/OwnerLoginRequest.java b/src/main/java/shopping/member/owner/application/dto/OwnerLoginRequest.java new file mode 100644 index 0000000..7d2c73a --- /dev/null +++ b/src/main/java/shopping/member/owner/application/dto/OwnerLoginRequest.java @@ -0,0 +1,12 @@ +package shopping.member.owner.application.dto; + +import jakarta.validation.constraints.NotBlank; + +public record OwnerLoginRequest( + @NotBlank + String email, + @NotBlank + String password +) { + +} diff --git a/src/main/java/shopping/member/owner/application/dto/OwnerLoginResponse.java b/src/main/java/shopping/member/owner/application/dto/OwnerLoginResponse.java new file mode 100644 index 0000000..1be0d3b --- /dev/null +++ b/src/main/java/shopping/member/owner/application/dto/OwnerLoginResponse.java @@ -0,0 +1,5 @@ +package shopping.member.owner.application.dto; + +public record OwnerLoginResponse(String accessToken) { + +} diff --git a/src/main/java/shopping/member/owner/domain/Owner.java b/src/main/java/shopping/member/owner/domain/Owner.java new file mode 100644 index 0000000..6cb879e --- /dev/null +++ b/src/main/java/shopping/member/owner/domain/Owner.java @@ -0,0 +1,17 @@ +package shopping.member.owner.domain; + +import jakarta.persistence.Entity; +import shopping.member.common.domain.Member; +import shopping.member.common.domain.MemberRole; +import shopping.member.common.domain.Password; + +@Entity +public class Owner extends Member { + + protected Owner() { + } + + public Owner(final String email, final Password password, final String memberName) { + super(email, password, memberName, MemberRole.OWNER); + } +} diff --git a/src/main/java/shopping/member/owner/domain/OwnerRepository.java b/src/main/java/shopping/member/owner/domain/OwnerRepository.java new file mode 100644 index 0000000..b657ade --- /dev/null +++ b/src/main/java/shopping/member/owner/domain/OwnerRepository.java @@ -0,0 +1,6 @@ +package shopping.member.owner.domain; + +public interface OwnerRepository { + + Owner save(Owner owner); +} diff --git a/src/main/java/shopping/member/owner/infra/JPAOwnerRepository.java b/src/main/java/shopping/member/owner/infra/JPAOwnerRepository.java new file mode 100644 index 0000000..8b6046d --- /dev/null +++ b/src/main/java/shopping/member/owner/infra/JPAOwnerRepository.java @@ -0,0 +1,9 @@ +package shopping.member.owner.infra; + +import org.springframework.data.jpa.repository.JpaRepository; +import shopping.member.owner.domain.Owner; +import shopping.member.owner.domain.OwnerRepository; + +public interface JPAOwnerRepository extends OwnerRepository, JpaRepository { + +} diff --git a/src/main/java/shopping/member/owner/ui/OwnerController.java b/src/main/java/shopping/member/owner/ui/OwnerController.java new file mode 100644 index 0000000..95c2307 --- /dev/null +++ b/src/main/java/shopping/member/owner/ui/OwnerController.java @@ -0,0 +1,34 @@ +package shopping.member.owner.ui; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +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 shopping.member.owner.application.OwnerLoginService; +import shopping.member.owner.application.dto.OwnerCreateRequest; +import shopping.member.owner.application.dto.OwnerLoginRequest; +import shopping.member.owner.application.dto.OwnerLoginResponse; + +@RequestMapping("/api/owner") +@RestController +@RequiredArgsConstructor +public class OwnerController { + + private final OwnerLoginService ownerLoginService; + + @PostMapping("/signup") + public ResponseEntity signUp(@RequestBody @Valid final OwnerCreateRequest request) { + ownerLoginService.signUp(request); + return ResponseEntity.ok().build(); + } + + @PostMapping("/login") + public ResponseEntity login( + @RequestBody @Valid final OwnerLoginRequest request) { + final OwnerLoginResponse response = ownerLoginService.login(request); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/shopping/product/application/ProductService.java b/src/main/java/shopping/product/application/ProductService.java new file mode 100644 index 0000000..42c5b26 --- /dev/null +++ b/src/main/java/shopping/product/application/ProductService.java @@ -0,0 +1,51 @@ +package shopping.product.application; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import shopping.product.application.dto.ProductCreateRequest; +import shopping.product.application.dto.ProductUpdateRequest; +import shopping.product.domain.Product; +import shopping.product.domain.ProductRepository; +import shopping.product.domain.ProfanityChecker; +import shopping.product.exception.NotFoundProductException; + +@Service +@RequiredArgsConstructor +public class ProductService { + + private final ProfanityChecker profanityChecker; + private final ProductRepository productRepository; + + public Long create(ProductCreateRequest request) { + final Product product = new Product(request.name(), + profanityChecker, + request.price(), + request.image()); + final Product saved = productRepository.save(product); + return saved.getId(); + } + + public void update(Long productId, ProductUpdateRequest request) { + final Product product = findProduct(productId); + product.update(request.name(), + profanityChecker, + request.price(), + request.image()); + productRepository.save(product); + } + + public void delete(Long id) { + final Product product = findProduct(id); + productRepository.delete(product); + } + + public Product findProduct(Long id) { + return productRepository.findById(id) + .orElseThrow(() -> new NotFoundProductException("해당 상품을 찾을 수 없습니다. " + id)); + } + + public List findProducts(List ids) { + return productRepository.findByIdIn(ids); + } +} diff --git a/src/main/java/shopping/product/application/WishEventHandler.java b/src/main/java/shopping/product/application/WishEventHandler.java new file mode 100644 index 0000000..1270d18 --- /dev/null +++ b/src/main/java/shopping/product/application/WishEventHandler.java @@ -0,0 +1,33 @@ +package shopping.product.application; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import shopping.product.application.event.UnWishEvent; +import shopping.product.application.event.WishEvent; +import shopping.product.domain.Product; + +@Component +@RequiredArgsConstructor +public class WishEventHandler { + + private final ProductService productService; + + @Async + @EventListener + @Transactional + public void handle(WishEvent wishEvent) { + final Product product = productService.findProduct(wishEvent.productId()); + product.wish(); + } + + @Async + @EventListener + @Transactional + public void handle(UnWishEvent wishEvent) { + final Product product = productService.findProduct(wishEvent.productId()); + product.unWish(); + } +} diff --git a/src/main/java/shopping/product/application/dto/ProductCreateRequest.java b/src/main/java/shopping/product/application/dto/ProductCreateRequest.java new file mode 100644 index 0000000..222ba78 --- /dev/null +++ b/src/main/java/shopping/product/application/dto/ProductCreateRequest.java @@ -0,0 +1,13 @@ +package shopping.product.application.dto; + +import jakarta.validation.constraints.NotBlank; + +public record ProductCreateRequest( + @NotBlank + String name, + @NotBlank + Long price, + String image +) { + +} diff --git a/src/main/java/shopping/product/application/dto/ProductUpdateRequest.java b/src/main/java/shopping/product/application/dto/ProductUpdateRequest.java new file mode 100644 index 0000000..02253b2 --- /dev/null +++ b/src/main/java/shopping/product/application/dto/ProductUpdateRequest.java @@ -0,0 +1,13 @@ +package shopping.product.application.dto; + +import jakarta.validation.constraints.NotBlank; + +public record ProductUpdateRequest( + @NotBlank + String name, + @NotBlank + Long price, + String image +) { + +} diff --git a/src/main/java/shopping/product/application/event/UnWishEvent.java b/src/main/java/shopping/product/application/event/UnWishEvent.java new file mode 100644 index 0000000..14b6988 --- /dev/null +++ b/src/main/java/shopping/product/application/event/UnWishEvent.java @@ -0,0 +1,7 @@ +package shopping.product.application.event; + +public record UnWishEvent( + Long productId +) { + +} diff --git a/src/main/java/shopping/product/application/event/WishEvent.java b/src/main/java/shopping/product/application/event/WishEvent.java new file mode 100644 index 0000000..1b6927a --- /dev/null +++ b/src/main/java/shopping/product/application/event/WishEvent.java @@ -0,0 +1,7 @@ +package shopping.product.application.event; + +public record WishEvent( + Long productId +) { + +} diff --git a/src/main/java/shopping/product/domain/InvalidProductNameException.java b/src/main/java/shopping/product/domain/InvalidProductNameException.java new file mode 100644 index 0000000..f1d7c94 --- /dev/null +++ b/src/main/java/shopping/product/domain/InvalidProductNameException.java @@ -0,0 +1,8 @@ +package shopping.product.domain; + +public class InvalidProductNameException extends IllegalStateException { + + public InvalidProductNameException(String s) { + super(s); + } +} diff --git a/src/main/java/shopping/product/domain/Product.java b/src/main/java/shopping/product/domain/Product.java new file mode 100644 index 0000000..6dc8554 --- /dev/null +++ b/src/main/java/shopping/product/domain/Product.java @@ -0,0 +1,73 @@ +package shopping.product.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import java.math.BigDecimal; +import lombok.Getter; +import shopping.common.domain.AuditableBaseEntity; + +@Entity +public class Product extends AuditableBaseEntity { + + @Id + @GeneratedValue + @Getter + @Column(name = "product_id", nullable = false) + private Long id; + + @Embedded + private ProductName name; + + @Embedded + private ProductPrice price; + + @Column(name = "product_image") + @Getter + private String image; + + @Column(name = "product_heart_count") + private Long heartCount; + + protected Product() { + } + + public Product(final String name, final ProfanityChecker profanityChecker, final Long price, + final String image) { + this(null, name, profanityChecker, price, image); + } + + public Product(final Long id, final String name, final ProfanityChecker profanityChecker, + final Long price, final String image) { + this.id = id; + this.name = new ProductName(name, profanityChecker); + this.price = new ProductPrice(price); + this.image = image; + this.heartCount = 0L; + } + + public void update(final String name, final ProfanityChecker profanityChecker, final Long price, + final String image) { + this.name = new ProductName(name, profanityChecker); + this.price = new ProductPrice(price); + this.image = image; + } + + public void wish() { + this.heartCount++; + } + + public void unWish() { + this.heartCount--; + } + + public String getName() { + return name.getName(); + } + + public BigDecimal getPrice() { + return price.getPrice(); + } +} diff --git a/src/main/java/shopping/product/domain/ProductName.java b/src/main/java/shopping/product/domain/ProductName.java new file mode 100644 index 0000000..08fdab4 --- /dev/null +++ b/src/main/java/shopping/product/domain/ProductName.java @@ -0,0 +1,59 @@ +package shopping.product.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import lombok.Getter; +import shopping.product.exception.InvalidProductNameException; + +@Embeddable +public class ProductName { + + private static final Pattern PRODUCT_NAME_PATTERN = Pattern.compile( + "^[a-zA-Z0-9()\\[\\]+\\-&/_가-힣]*$"); + + + @Column(name = "product_name", nullable = false) + @Getter + private String name; + + protected ProductName() { + } + + public ProductName(final String name, final ProfanityChecker profanityChecker) { + if (name == null || profanityChecker == null) { + throw new InvalidProductNameException("상품 이름은 필수값 입니다"); + } + + validateName(name, profanityChecker); + this.name = name; + } + + private static void validateName(final String name, final ProfanityChecker profanityChecker) { + validateNameLength(name); + validateNamePattern(name); + validateProfanity(name, profanityChecker); + } + + private static void validateNameLength(final String name) { + if (name.length() > 15) { + throw new InvalidProductNameException("상품 이름은 최대 15글자까지 입력가능합니다. " + name); + } + } + + private static void validateNamePattern(final String name) { + final Matcher nameMatcher = PRODUCT_NAME_PATTERN.matcher(name); + if (!nameMatcher.matches()) { + throw new InvalidProductNameException( + "상품 이름에 (), [], +, -, &, /, _ 을 제외한 특수문자는 들어갈 수 없습니다. " + name); + } + } + + private static void validateProfanity(final String name, + final ProfanityChecker profanityChecker) { + if (profanityChecker.isProfanity(name)) { + throw new InvalidProductNameException("상품 이름에 비속어를 사용할 수 없습니다. " + name); + } + } +} diff --git a/src/main/java/shopping/product/domain/ProductPrice.java b/src/main/java/shopping/product/domain/ProductPrice.java new file mode 100644 index 0000000..291c18d --- /dev/null +++ b/src/main/java/shopping/product/domain/ProductPrice.java @@ -0,0 +1,30 @@ +package shopping.product.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import java.math.BigDecimal; +import lombok.Getter; +import shopping.product.exception.InvalidProductPriceException; + +@Embeddable +public class ProductPrice { + + @Column(name = "product_price", nullable = false) + @Getter + private BigDecimal price; + + protected ProductPrice() { + } + + public ProductPrice(final Long price) { + if (price == null) { + throw new InvalidProductPriceException("상품 금액은 필수값 입니다."); + } + + if (price < 0) { + throw new InvalidProductPriceException("상품 금액은 0원 이상이어야 합니다. " + price); + } + + this.price = BigDecimal.valueOf(price); + } +} diff --git a/src/main/java/shopping/product/domain/ProductRepository.java b/src/main/java/shopping/product/domain/ProductRepository.java new file mode 100644 index 0000000..a47f56c --- /dev/null +++ b/src/main/java/shopping/product/domain/ProductRepository.java @@ -0,0 +1,15 @@ +package shopping.product.domain; + +import java.util.List; +import java.util.Optional; + +public interface ProductRepository { + + Product save(Product product); + + void delete(Product product); + + Optional findById(Long id); + + List findByIdIn(List ids); +} diff --git a/src/main/java/shopping/product/domain/ProfanityChecker.java b/src/main/java/shopping/product/domain/ProfanityChecker.java new file mode 100644 index 0000000..be0328b --- /dev/null +++ b/src/main/java/shopping/product/domain/ProfanityChecker.java @@ -0,0 +1,6 @@ +package shopping.product.domain; + +public interface ProfanityChecker { + + boolean isProfanity(String text); +} diff --git a/src/main/java/shopping/product/exception/InvalidProductNameException.java b/src/main/java/shopping/product/exception/InvalidProductNameException.java new file mode 100644 index 0000000..bf3449f --- /dev/null +++ b/src/main/java/shopping/product/exception/InvalidProductNameException.java @@ -0,0 +1,10 @@ +package shopping.product.exception; + +import shopping.common.exception.SpringShoppingException; + +public class InvalidProductNameException extends SpringShoppingException { + + public InvalidProductNameException(String s) { + super(s); + } +} diff --git a/src/main/java/shopping/product/exception/InvalidProductPriceException.java b/src/main/java/shopping/product/exception/InvalidProductPriceException.java new file mode 100644 index 0000000..e7d6a1a --- /dev/null +++ b/src/main/java/shopping/product/exception/InvalidProductPriceException.java @@ -0,0 +1,10 @@ +package shopping.product.exception; + +import shopping.common.exception.SpringShoppingException; + +public class InvalidProductPriceException extends SpringShoppingException { + + public InvalidProductPriceException(final String s) { + super(s); + } +} diff --git a/src/main/java/shopping/product/exception/NotFoundProductException.java b/src/main/java/shopping/product/exception/NotFoundProductException.java new file mode 100644 index 0000000..69071ed --- /dev/null +++ b/src/main/java/shopping/product/exception/NotFoundProductException.java @@ -0,0 +1,10 @@ +package shopping.product.exception; + +import shopping.common.exception.SpringShoppingException; + +public class NotFoundProductException extends SpringShoppingException { + + public NotFoundProductException(final String message) { + super(message); + } +} diff --git a/src/main/java/shopping/product/infra/DefaultProfanityChecker.java b/src/main/java/shopping/product/infra/DefaultProfanityChecker.java new file mode 100644 index 0000000..aee3455 --- /dev/null +++ b/src/main/java/shopping/product/infra/DefaultProfanityChecker.java @@ -0,0 +1,22 @@ +package shopping.product.infra; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import shopping.product.domain.ProfanityChecker; + +@Component +@RequiredArgsConstructor +public class DefaultProfanityChecker implements ProfanityChecker { + + private final RestClient restClient; + + @Override + public boolean isProfanity(final String text) { + final String response = restClient.get() + .uri("https://www.purgomalum.com/service/containsprofanity?text={text}", text) + .retrieve() + .body(String.class); + return Boolean.parseBoolean(response); + } +} diff --git a/src/main/java/shopping/product/infra/JPAProductRepository.java b/src/main/java/shopping/product/infra/JPAProductRepository.java new file mode 100644 index 0000000..4c36364 --- /dev/null +++ b/src/main/java/shopping/product/infra/JPAProductRepository.java @@ -0,0 +1,9 @@ +package shopping.product.infra; + +import org.springframework.data.jpa.repository.JpaRepository; +import shopping.product.domain.Product; +import shopping.product.domain.ProductRepository; + +public interface JPAProductRepository extends ProductRepository, JpaRepository { + +} diff --git a/src/main/java/shopping/product/ui/ProductController.java b/src/main/java/shopping/product/ui/ProductController.java new file mode 100644 index 0000000..5fab7db --- /dev/null +++ b/src/main/java/shopping/product/ui/ProductController.java @@ -0,0 +1,56 @@ +package shopping.product.ui; + +import jakarta.validation.Valid; +import java.net.URI; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import shopping.product.application.ProductService; +import shopping.product.application.dto.ProductCreateRequest; +import shopping.product.application.dto.ProductUpdateRequest; +import shopping.product.domain.Product; + +@RequestMapping("/api/products") +@RestController +@RequiredArgsConstructor +public class ProductController { + + private final ProductService productService; + + @PostMapping() + @PreAuthorize("hasRole('OWNER')") + public ResponseEntity create(@RequestBody @Valid final ProductCreateRequest request) { + final Long saved = productService.create(request); + return ResponseEntity.created(URI.create("/api/products/" + saved)) + .build(); + } + + @PutMapping("/{productId}") + @PreAuthorize("hasRole('OWNER')") + public ResponseEntity update(@PathVariable Long productId, + @RequestBody @Valid final ProductUpdateRequest request) { + productService.update(productId, request); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/{productId}") + @PreAuthorize("hasRole('OWNER')") + public ResponseEntity delete(@PathVariable Long productId) { + productService.delete(productId); + return ResponseEntity.ok().build(); + } + + @GetMapping("/{productId}") + public ResponseEntity find(@PathVariable Long productId) { + final Product product = productService.findProduct(productId); + return ResponseEntity.ok().body(product); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index c33078c..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=spring-shopping diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..c8b677d --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,23 @@ +jwt: + secret: VlwEyVBsYt9V7zq57TejMnVUyzblYcfPQye08f7MGVA9XkHa +spring: + h2: + console: + enabled: true + path: /h2-console + datasource: + url: jdbc:h2:mem:testdb + driver-class-name: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: create-drop + database-platform: org.hibernate.dialect.H2Dialect + properties: + hibernate: + format_sql: true + show_sql: true +logging: + level: + org.hibernate.SQL: debug diff --git a/src/test/java/shopping/fake/FakeClientRepository.java b/src/test/java/shopping/fake/FakeClientRepository.java new file mode 100644 index 0000000..083b7c0 --- /dev/null +++ b/src/test/java/shopping/fake/FakeClientRepository.java @@ -0,0 +1,18 @@ +package shopping.fake; + +import shopping.member.client.domain.Client; +import shopping.member.client.domain.ClientRepository; + +public class FakeClientRepository implements ClientRepository { + + private final InMemoryMembers inMemoryMembers; + + public FakeClientRepository(InMemoryMembers inMemoryMembers) { + this.inMemoryMembers = inMemoryMembers; + } + + @Override + public Client save(final Client client) { + return (Client) inMemoryMembers.save(client); + } +} diff --git a/src/test/java/shopping/fake/FakeMemberRepository.java b/src/test/java/shopping/fake/FakeMemberRepository.java new file mode 100644 index 0000000..4773d8b --- /dev/null +++ b/src/test/java/shopping/fake/FakeMemberRepository.java @@ -0,0 +1,19 @@ +package shopping.fake; + +import java.util.Optional; +import shopping.member.common.domain.Member; +import shopping.member.common.domain.MemberRepository; + +public class FakeMemberRepository implements MemberRepository { + + private final InMemoryMembers inMemoryMembers; + + public FakeMemberRepository(InMemoryMembers inMemoryMembers) { + this.inMemoryMembers = inMemoryMembers; + } + + @Override + public Optional findByEmail(final String email) { + return inMemoryMembers.findByEmail(email); + } +} diff --git a/src/test/java/shopping/fake/FakeOwnerRepository.java b/src/test/java/shopping/fake/FakeOwnerRepository.java new file mode 100644 index 0000000..a70cff5 --- /dev/null +++ b/src/test/java/shopping/fake/FakeOwnerRepository.java @@ -0,0 +1,18 @@ +package shopping.fake; + +import shopping.member.owner.domain.Owner; +import shopping.member.owner.domain.OwnerRepository; + +public class FakeOwnerRepository implements OwnerRepository { + + private final InMemoryMembers inMemoryMembers; + + public FakeOwnerRepository(InMemoryMembers inMemoryMembers) { + this.inMemoryMembers = inMemoryMembers; + } + + @Override + public Owner save(final Owner owner) { + return (Owner) inMemoryMembers.save(owner); + } +} diff --git a/src/test/java/shopping/fake/FakePasswordEncoder.java b/src/test/java/shopping/fake/FakePasswordEncoder.java new file mode 100644 index 0000000..699d3eb --- /dev/null +++ b/src/test/java/shopping/fake/FakePasswordEncoder.java @@ -0,0 +1,21 @@ +package shopping.fake; + +import shopping.member.common.domain.PasswordEncoder; + +public class FakePasswordEncoder implements PasswordEncoder { + + @Override + public String encode(final String rawPassword) { + return reversePassword(rawPassword); + } + + @Override + public boolean matches(final String rawPassword, final String password) { + return reversePassword(rawPassword).equals(password); + } + + private String reversePassword(final String rawPassword) { + final StringBuilder stringBuilder = new StringBuilder(rawPassword); + return stringBuilder.reverse().toString(); + } +} diff --git a/src/test/java/shopping/fake/FakeProductRepository.java b/src/test/java/shopping/fake/FakeProductRepository.java new file mode 100644 index 0000000..766ca15 --- /dev/null +++ b/src/test/java/shopping/fake/FakeProductRepository.java @@ -0,0 +1,38 @@ +package shopping.fake; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import shopping.product.domain.Product; +import shopping.product.domain.ProductRepository; + +public class FakeProductRepository implements ProductRepository { + + private final Map products = new HashMap<>(); + + @Override + public Product save(final Product product) { + products.put(product.getId(), product); + return product; + } + + @Override + public void delete(final Product product) { + products.remove(product.getId()); + } + + @Override + public Optional findById(final Long id) { + return Optional.ofNullable(products.get(id)); + } + + @Override + public List findByIdIn(final List ids) { + return ids.stream() + .map(products::get) + .filter(Objects::nonNull) + .toList(); + } +} diff --git a/src/test/java/shopping/fake/FakeProfanityChecker.java b/src/test/java/shopping/fake/FakeProfanityChecker.java new file mode 100644 index 0000000..4e17efd --- /dev/null +++ b/src/test/java/shopping/fake/FakeProfanityChecker.java @@ -0,0 +1,15 @@ +package shopping.fake; + +import java.util.List; +import shopping.product.domain.ProfanityChecker; + +public class FakeProfanityChecker implements ProfanityChecker { + + private static final List profanities = List.of("비속어", "욕", "메롱"); + + @Override + public boolean isProfanity(final String text) { + return profanities.stream() + .anyMatch(text::contains); + } +} diff --git a/src/test/java/shopping/fake/InMemoryMembers.java b/src/test/java/shopping/fake/InMemoryMembers.java new file mode 100644 index 0000000..4952586 --- /dev/null +++ b/src/test/java/shopping/fake/InMemoryMembers.java @@ -0,0 +1,20 @@ +package shopping.fake; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import shopping.member.common.domain.Member; + +public class InMemoryMembers { + + private final Map members = new HashMap<>(); + + public Member save(final Member member) { + members.put(member.getEmail(), member); + return member; + } + + public Optional findByEmail(final String email) { + return Optional.ofNullable(members.get(email)); + } +} diff --git a/src/test/java/shopping/fixture/ClientFixture.java b/src/test/java/shopping/fixture/ClientFixture.java new file mode 100644 index 0000000..7b2321f --- /dev/null +++ b/src/test/java/shopping/fixture/ClientFixture.java @@ -0,0 +1,13 @@ +package shopping.fixture; + +import shopping.fake.FakePasswordEncoder; +import shopping.member.client.domain.Client; +import shopping.member.common.domain.Password; + +public class ClientFixture { + + public static Client createClient() { + final Password password = new Password("1234", new FakePasswordEncoder()); + return new Client("test@test.com", password, "test"); + } +} diff --git a/src/test/java/shopping/fixture/ProductFixture.java b/src/test/java/shopping/fixture/ProductFixture.java new file mode 100644 index 0000000..7339a94 --- /dev/null +++ b/src/test/java/shopping/fixture/ProductFixture.java @@ -0,0 +1,12 @@ +package shopping.fixture; + +import shopping.fake.FakeProfanityChecker; +import shopping.product.domain.Product; + +public class ProductFixture { + + public static Product createProduct() { + final FakeProfanityChecker profanityChecker = new FakeProfanityChecker(); + return new Product(1L, "맥북", profanityChecker, 1_000L, "image.jpg"); + } +} diff --git a/src/test/java/shopping/member/client/applicaton/ClientLoginServiceTest.java b/src/test/java/shopping/member/client/applicaton/ClientLoginServiceTest.java new file mode 100644 index 0000000..bc4f5aa --- /dev/null +++ b/src/test/java/shopping/member/client/applicaton/ClientLoginServiceTest.java @@ -0,0 +1,60 @@ +package shopping.member.client.applicaton; + +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import shopping.fake.FakeClientRepository; +import shopping.fake.FakeMemberRepository; +import shopping.fake.FakePasswordEncoder; +import shopping.fake.InMemoryMembers; +import shopping.member.client.applicaton.dto.ClientCreateRequest; +import shopping.member.client.domain.ClientRepository; +import shopping.member.common.application.AuthService; +import shopping.member.common.domain.MemberRepository; +import shopping.member.common.exception.InvalidEmailException; + +@DisplayName("ClientLoginService") +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class ClientLoginServiceTest { + + private ClientLoginService clientService; + + @BeforeEach + void setUp() { + final InMemoryMembers inMemoryMembers = new InMemoryMembers(); + final MemberRepository memberRepository = new FakeMemberRepository(inMemoryMembers); + final AuthService authService = new AuthService( + memberRepository, + new FakePasswordEncoder(), + (email, token) -> email + " " + token); + final ClientRepository clientRepository = new FakeClientRepository(inMemoryMembers); + clientService = new ClientLoginService(authService, clientRepository); + } + + @Test + void 회원가입을_진행할_수_있다() { + assertThatNoException().isThrownBy(() -> clientService.signUp(createRequest())); + } + + @Test + void 같은_이메일로_중복해서_가입할_수_없다() { + final ClientCreateRequest request = createRequest(); + clientService.signUp(request); + + assertThatThrownBy(() -> clientService.signUp(request)) + .isInstanceOf(InvalidEmailException.class); + } + + private ClientCreateRequest createRequest() { + return new ClientCreateRequest( + "test@test.com", + "1234", + "test" + ); + } +} \ No newline at end of file diff --git a/src/test/java/shopping/member/client/applicaton/ClientWishServiceTest.java b/src/test/java/shopping/member/client/applicaton/ClientWishServiceTest.java new file mode 100644 index 0000000..25b668d --- /dev/null +++ b/src/test/java/shopping/member/client/applicaton/ClientWishServiceTest.java @@ -0,0 +1,115 @@ +package shopping.member.client.applicaton; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import shopping.fake.FakeProductRepository; +import shopping.fake.FakeProfanityChecker; +import shopping.fixture.ClientFixture; +import shopping.fixture.ProductFixture; +import shopping.member.client.applicaton.dto.WishProductResponse; +import shopping.member.client.domain.Client; +import shopping.member.client.exception.DuplicateWishProductException; +import shopping.member.client.exception.NotFoundWishProductException; +import shopping.product.application.ProductService; +import shopping.product.domain.Product; +import shopping.product.domain.ProductRepository; +import shopping.product.exception.NotFoundProductException; + +@DisplayName("ClientWishService") +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class ClientWishServiceTest { + + private final ProductRepository productRepository = new FakeProductRepository(); + private ClientWishService clientWishService; + + @BeforeEach + void setUp() { + final ProductService productService = new ProductService( + new FakeProfanityChecker(), + productRepository); + final WishProductMapper wishProductMapper = new WishProductMapper(productService); + clientWishService = new ClientWishService(wishProductMapper, + (event) -> System.out.println("이벤트 발행")); + } + + @Test + void 존재하지_않는_Product의_하트를_누르면_예외를_던진다() { + assertThatThrownBy(() -> clientWishService.wish(1L, createClient())) + .isInstanceOf(NotFoundProductException.class); + } + + @Test + void Product의_하트를_누르면_Client의_위시상품리스트에_추가된다() { + final Client client = createClient(); + final Product product = createProduct(); + + clientWishService.wish(product.getId(), client); + + assertThat(client.productIds()).hasSize(1); + } + + @Test + void 중복되는_Product의_하트를_누르면_예외를_던진다() { + final Client client = createClient(); + final Product product = createProduct(); + clientWishService.wish(product.getId(), client); + + assertThatThrownBy(() -> clientWishService.wish(product.getId(), client)) + .isInstanceOf(DuplicateWishProductException.class); + } + + @Test + void Product의_하트를_취소하면_Client의_위시상품리스트에서_삭제된다() { + final Client client = createClient(); + final Product product = createProduct(); + clientWishService.wish(product.getId(), client); + + clientWishService.unWish(product.getId(), client); + + assertThat(client.productIds()).hasSize(0); + } + + @Test + void 위시상품에_없는_상품의_하트를_취소하면_예외를_던진다() { + final Client client = createClient(); + + assertThatThrownBy(() -> clientWishService.unWish(1L, client)) + .isInstanceOf(NotFoundWishProductException.class); + } + + @Test + void 위시상품리스트에_추가된_상품들을_조회할_수_있다() { + final Client client = createClient(); + final Product product = createProduct(); + clientWishService.wish(product.getId(), client); + + final List responses = clientWishService.findAll(client); + + assertThat(responses).hasSize(1); + } + + @Test + void 위시상품조회_시_추가된_상품이_없다면_빈리스트를_반환한다() { + final Client client = createClient(); + + final List responses = clientWishService.findAll(client); + + assertThat(responses).hasSize(0); + } + + private Client createClient() { + return ClientFixture.createClient(); + } + + private Product createProduct() { + final Product product = ProductFixture.createProduct(); + return productRepository.save(product); + } +} \ No newline at end of file diff --git a/src/test/java/shopping/member/client/domain/ClientTest.java b/src/test/java/shopping/member/client/domain/ClientTest.java new file mode 100644 index 0000000..e0d6704 --- /dev/null +++ b/src/test/java/shopping/member/client/domain/ClientTest.java @@ -0,0 +1,20 @@ +package shopping.member.client.domain; + +import static org.assertj.core.api.Assertions.assertThatNoException; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import shopping.fixture.ClientFixture; + +@DisplayName("Client") +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class ClientTest { + + @Test + void Client를_생성할_수_있다() { + assertThatNoException() + .isThrownBy(ClientFixture::createClient); + } +} \ No newline at end of file diff --git a/src/test/java/shopping/member/client/domain/WishProductsTest.java b/src/test/java/shopping/member/client/domain/WishProductsTest.java new file mode 100644 index 0000000..c301b18 --- /dev/null +++ b/src/test/java/shopping/member/client/domain/WishProductsTest.java @@ -0,0 +1,43 @@ +package shopping.member.client.domain; + +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import shopping.member.client.exception.DuplicateWishProductException; +import shopping.member.client.exception.NotFoundWishProductException; + +@DisplayName("WishProducts") +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class WishProductsTest { + + @Test + void WishProducts에_위시상품을_추가했다가_삭제할_수_있다() { + final WishProducts wishProducts = new WishProducts(); + + assertThatNoException().isThrownBy(() -> { + wishProducts.wish(1L); + wishProducts.unWish(1L); + }); + } + + @Test + void WishProducts에_위시상품을_중복해서_추가할_수_없다() { + final WishProducts wishProducts = new WishProducts(); + wishProducts.wish(1L); + + assertThatThrownBy(() -> wishProducts.wish(1L)) + .isInstanceOf(DuplicateWishProductException.class); + } + + @Test + void WishProducts에_없는_위시상품을_삭제할_수_없다() { + final WishProducts wishProducts = new WishProducts(); + + assertThatThrownBy(() -> wishProducts.unWish(1L)) + .isInstanceOf(NotFoundWishProductException.class); + } +} \ No newline at end of file diff --git a/src/test/java/shopping/member/common/application/AuthServiceTest.java b/src/test/java/shopping/member/common/application/AuthServiceTest.java new file mode 100644 index 0000000..9e37ec6 --- /dev/null +++ b/src/test/java/shopping/member/common/application/AuthServiceTest.java @@ -0,0 +1,93 @@ +package shopping.member.common.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import shopping.fake.FakeMemberRepository; +import shopping.fake.FakePasswordEncoder; +import shopping.fake.InMemoryMembers; +import shopping.member.client.domain.Client; +import shopping.member.common.domain.MemberRepository; +import shopping.member.common.domain.MemberRole; +import shopping.member.common.domain.Password; +import shopping.member.common.domain.PasswordEncoder; +import shopping.member.common.exception.InvalidEmailException; +import shopping.member.common.exception.InvalidMemberException; +import shopping.member.common.exception.InvalidPasswordException; +import shopping.member.common.exception.NotFoundMemberException; + +@DisplayName("AuthService") +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class AuthServiceTest { + + private final InMemoryMembers inMemoryMembers = new InMemoryMembers(); + private final PasswordEncoder passwordEncoder = new FakePasswordEncoder(); + private AuthService authService; + + @BeforeEach + void setUp() { + final MemberRepository memberRepository = new FakeMemberRepository(inMemoryMembers); + authService = new AuthService( + memberRepository, + passwordEncoder, + (email, role) -> email + " " + email + ); + } + + @Test + void 중복되는_이메일을_검증할_수_있다() { + saveMember(); + + assertThatThrownBy(() -> authService.validateEmail("test@test.com")) + .isInstanceOf(InvalidEmailException.class); + } + + @ParameterizedTest + @ValueSource(strings = {"1234", "qwer", "password"}) + void 패스워드를_암호화한다(String rawPassword) { + final Password password = authService.encodePassword(rawPassword); + + assertThat(password.isMatch(rawPassword, passwordEncoder)).isTrue(); + } + + @Test + void 로그인을_수행한다() { + saveMember(); + + assertThat(authService.login("test@test.com", "1234", MemberRole.CLIENT)).isNotNull(); + } + + @Test + void 가입되지않은_멤버의_로그인을_수행하면_예외를_던진다() { + assertThatThrownBy(() -> authService.login("test@test.com", "1234", MemberRole.CLIENT)) + .isInstanceOf(NotFoundMemberException.class); + } + + @Test + void 로그인수행_시_비밀번호를_틀리면_예외를_던진다() { + saveMember(); + + assertThatThrownBy(() -> authService.login("test@test.com", "1111", MemberRole.CLIENT)) + .isInstanceOf(InvalidPasswordException.class); + } + + @Test + void 로그인수행_시_권한이_없다면_예외를_던진다() { + saveMember(); + + assertThatThrownBy(() -> authService.login("test@test.com", "1234", MemberRole.OWNER)) + .isInstanceOf(InvalidMemberException.class); + } + + private void saveMember() { + final Password password = new Password("1234", passwordEncoder); + inMemoryMembers.save(new Client("test@test.com", password, "test")); + } +} \ No newline at end of file diff --git a/src/test/java/shopping/member/common/domain/MemberTest.java b/src/test/java/shopping/member/common/domain/MemberTest.java new file mode 100644 index 0000000..ce63d79 --- /dev/null +++ b/src/test/java/shopping/member/common/domain/MemberTest.java @@ -0,0 +1,43 @@ +package shopping.member.common.domain; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import shopping.fake.FakePasswordEncoder; +import shopping.member.client.domain.Client; + +@DisplayName("Member") +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class MemberTest { + + private final PasswordEncoder passwordEncoder = new FakePasswordEncoder(); + + @Test + void 올바른_Password인지_확인할_수_있다() { + final Member member = createMember(); + + assertThat(member.isValidPassword("1111", passwordEncoder)).isFalse(); + } + + @Test + void Member가_어떤_권한을_가졌는지_얻을_수_있다() { + final Member member = createMember(); + + assertThat(member.getMemberRole()).isEqualTo("Client"); + } + + @Test + void Member가_유효한_권한을_가졌는지_확인할_수_있다() { + final Member member = createMember(); + + assertThat(member.isValidRole(MemberRole.CLIENT)).isTrue(); + } + + private Member createMember() { + final Password password = new Password("1234", passwordEncoder); + return new Client("test@test.com", password, "test"); + } +} \ No newline at end of file diff --git a/src/test/java/shopping/member/common/domain/PasswordTest.java b/src/test/java/shopping/member/common/domain/PasswordTest.java new file mode 100644 index 0000000..f75269f --- /dev/null +++ b/src/test/java/shopping/member/common/domain/PasswordTest.java @@ -0,0 +1,33 @@ +package shopping.member.common.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import shopping.fake.FakePasswordEncoder; + +@DisplayName("Password") +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class PasswordTest { + + private final PasswordEncoder passwordEncoder = new FakePasswordEncoder(); + + @Test + void Password를_생성할_수_있다() { + assertThatNoException() + .isThrownBy(() -> new Password("1234", passwordEncoder)); + } + + @ParameterizedTest + @ValueSource(strings = {"1234", "qwer", "password"}) + void Password가_맞는지_확인할_수_있다(String rawPassword) { + final Password password = new Password(rawPassword, passwordEncoder); + + assertThat(password.isMatch(rawPassword, passwordEncoder)).isTrue(); + } +} \ No newline at end of file diff --git a/src/test/java/shopping/member/owner/application/OwnerLoginServiceTest.java b/src/test/java/shopping/member/owner/application/OwnerLoginServiceTest.java new file mode 100644 index 0000000..9f6ad3f --- /dev/null +++ b/src/test/java/shopping/member/owner/application/OwnerLoginServiceTest.java @@ -0,0 +1,60 @@ +package shopping.member.owner.application; + +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import shopping.fake.FakeMemberRepository; +import shopping.fake.FakeOwnerRepository; +import shopping.fake.FakePasswordEncoder; +import shopping.fake.InMemoryMembers; +import shopping.member.common.application.AuthService; +import shopping.member.common.domain.MemberRepository; +import shopping.member.common.exception.InvalidEmailException; +import shopping.member.owner.application.dto.OwnerCreateRequest; +import shopping.member.owner.domain.OwnerRepository; + +@DisplayName("OwnerLoginService") +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class OwnerLoginServiceTest { + + private OwnerLoginService ownerService; + + @BeforeEach + void setUp() { + final InMemoryMembers inMemoryMembers = new InMemoryMembers(); + final MemberRepository memberRepository = new FakeMemberRepository(inMemoryMembers); + final AuthService authService = new AuthService( + memberRepository, + new FakePasswordEncoder(), + (email, token) -> email + " " + token); + final OwnerRepository ownerRepository = new FakeOwnerRepository(inMemoryMembers); + ownerService = new OwnerLoginService(authService, ownerRepository); + } + + @Test + void 회원가입을_진행할_수_있다() { + assertThatNoException().isThrownBy(() -> ownerService.signUp(createRequest())); + } + + @Test + void 같은_이메일로_중복해서_가입할_수_없다() { + final OwnerCreateRequest request = createRequest(); + ownerService.signUp(request); + + assertThatThrownBy(() -> ownerService.signUp(request)) + .isInstanceOf(InvalidEmailException.class); + } + + private OwnerCreateRequest createRequest() { + return new OwnerCreateRequest( + "admin@test.com", + "1234", + "admin" + ); + } +} \ No newline at end of file diff --git a/src/test/java/shopping/product/domain/ProductNameTest.java b/src/test/java/shopping/product/domain/ProductNameTest.java new file mode 100644 index 0000000..1136af2 --- /dev/null +++ b/src/test/java/shopping/product/domain/ProductNameTest.java @@ -0,0 +1,66 @@ +package shopping.product.domain; + +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import shopping.fake.FakeProfanityChecker; +import shopping.product.exception.InvalidProductNameException; + +@DisplayName("ProductName") +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class ProductNameTest { + + private ProfanityChecker profanityChecker = new FakeProfanityChecker(); + + @Test + void 이름이_null인_ProductName을_생성하면_예외를_던진다() { + assertThatThrownBy( + () -> new ProductName(null, profanityChecker)) + .isInstanceOf(InvalidProductNameException.class); + } + + @Test + void 비속어체커가_null인_ProductName을_생성하면_예외를_던진다() { + assertThatThrownBy( + () -> new ProductName("맥북", null)) + .isInstanceOf(InvalidProductNameException.class); + } + + @ParameterizedTest + @ValueSource(strings = {"가나다라마바사아자차카파타하하하", "1234567890123456", " "}) + void 이름의_길이가_15자를_넘어가는_ProductNmae을_생성하면_예외를_던진다(String name) { + assertThatThrownBy( + () -> new ProductName(name, profanityChecker)) + .isInstanceOf(InvalidProductNameException.class); + } + + @ParameterizedTest + @DisplayName("이름에 (), [], +, -, &, /, _ 을 제외한 특수문자가 들어가는 ProductName을 생성하면 예외를 던진다.") + @ValueSource(strings = {"*맥북*", "{맥북}", "^맥북^"}) + void validatePattern(String name) { + assertThatThrownBy( + () -> new ProductName(name, profanityChecker)) + .isInstanceOf(InvalidProductNameException.class); + } + + @ParameterizedTest + @ValueSource(strings = {"비속어맥북", "욕욕욕", "메롱롱롱"}) + void 이름에_비속어가_들어가는_ProductName을_생성하면_예외를_던진다(String name) { + assertThatThrownBy( + () -> new ProductName(name, profanityChecker)) + .isInstanceOf(InvalidProductNameException.class); + } + + @ParameterizedTest + @ValueSource(strings = {"맥북", "아이패드", "아이폰"}) + void ProductName을_생성할_수_있다(String name) { + assertThatNoException() + .isThrownBy(() -> new ProductName(name, profanityChecker)); + } +} \ No newline at end of file diff --git a/src/test/java/shopping/product/domain/ProductPriceTest.java b/src/test/java/shopping/product/domain/ProductPriceTest.java new file mode 100644 index 0000000..ae33a61 --- /dev/null +++ b/src/test/java/shopping/product/domain/ProductPriceTest.java @@ -0,0 +1,37 @@ +package shopping.product.domain; + +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import shopping.product.exception.InvalidProductPriceException; + +@DisplayName("ProductPrice") +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class ProductPriceTest { + + @Test + void 가격이_null인_ProductPrice를_생성하면_예외를_던진다() { + assertThatThrownBy(() -> new ProductPrice(null)) + .isInstanceOf(InvalidProductPriceException.class); + } + + @ParameterizedTest + @ValueSource(longs = {-1L, -100L, -100_000L}) + void 가격이_0미만인_ProductPrice를_생성하면_예외를_던진다(long price) { + assertThatThrownBy(() -> new ProductPrice(price)) + .isInstanceOf(InvalidProductPriceException.class); + } + + @ParameterizedTest + @ValueSource(longs = {1L, 100L, 100_000L}) + void ProductPrice를_생성할_수_있다(long price) { + assertThatNoException() + .isThrownBy(() -> new ProductPrice(price)); + } +} diff --git a/src/test/java/shopping/product/domain/ProductTest.java b/src/test/java/shopping/product/domain/ProductTest.java new file mode 100644 index 0000000..8b4be99 --- /dev/null +++ b/src/test/java/shopping/product/domain/ProductTest.java @@ -0,0 +1,20 @@ +package shopping.product.domain; + +import static org.assertj.core.api.Assertions.assertThatNoException; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import shopping.fixture.ProductFixture; + +@DisplayName("Product") +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class ProductTest { + + @Test + void Product를_생성할_수_있다() { + assertThatNoException() + .isThrownBy(ProductFixture::createProduct); + } +} \ No newline at end of file diff --git a/src/test/java/shopping/study/RestClientTest.java b/src/test/java/shopping/study/RestClientTest.java new file mode 100644 index 0000000..4ce9aed --- /dev/null +++ b/src/test/java/shopping/study/RestClientTest.java @@ -0,0 +1,36 @@ +package shopping.study; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.web.client.RestClient; + +@Disabled +public class RestClientTest { + + private static final String URL = "https://www.purgomalum.com/service/containsprofanity?text={text}"; + private static final RestClient restClient = RestClient.create(); + + @ParameterizedTest + @CsvSource(value = { + "fuck:true", + "apple:false", + "bitch:true", + "banana:false", + "fucking:true" + }, delimiter = ':') + void RestClient객체를_만들어_Purogomalum에_비속어체크를_요청한다(String text, boolean res) { + final boolean response = isProfanity(text); + assertThat(response).isEqualTo(res); + } + + private boolean isProfanity(final String text) { + final String response = restClient.get() + .uri(URL, text) + .retrieve() + .body(String.class); + return Boolean.parseBoolean(response); + } +}