diff --git a/.github/ISSUE_TEMPLATE/bug-fix-template.md b/.github/ISSUE_TEMPLATE/bug-fix-template.md new file mode 100644 index 0000000..3e5f17f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-fix-template.md @@ -0,0 +1,17 @@ +--- +name: "\bBug Fix Template" +about: 버그 이슈 템플릿 +title: "[BUG] " +labels: bug +assignees: '' + +--- + +## 🐞 어떤 상황에서 발생한 버그인가요? +> 어떤 상황에서 발생한 버그인지 설명해주세요 + +## 🎁 수정한 내용 +> 어떤 작업으로 버그를 잡았는지 설명해주세요 + + +## 🔍 참고할만한 자료(선택) diff --git a/.github/ISSUE_TEMPLATE/feature-template.md b/.github/ISSUE_TEMPLATE/feature-template.md new file mode 100644 index 0000000..3ff4034 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-template.md @@ -0,0 +1,20 @@ +--- +name: Feature Template +about: 기능 추가 이슈 템플릿 +title: "[FEAT] " +labels: feature +assignees: '' + +--- + +## 📌 어떤 기능인가요? +> 추가하려는 기능에 대해 간결하게 설명해주세요 + + +## 📜 작업 상세 내용 +- [ ] TODO +- [ ] TODO +- [ ] TODO + + +## 🔍 참고할만한 자료(선택) \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/refactor-issue-template.md b/.github/ISSUE_TEMPLATE/refactor-issue-template.md new file mode 100644 index 0000000..7d3d8ee --- /dev/null +++ b/.github/ISSUE_TEMPLATE/refactor-issue-template.md @@ -0,0 +1,19 @@ +--- +name: Refactor issue template +about: 리팩터링 이슈 템플릿 +title: "[REFACTOR] " +labels: refactor +assignees: '' + +--- + +## 📌 어떤 기능을 리팩터링 하나요? +> 리팩터링 할 기능에 대해 간결하게 설명해주세요 + +## AS-IS +> 현재 인식한 상황에 대해 설명해주세요 + +## TO-BE +> 현재의 상황에서 개선시킬 이상적인 지향점을 설명해주세요 + +## 🔍 참고할만한 자료(선택) \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/yummy-issue-template.md b/.github/ISSUE_TEMPLATE/yummy-issue-template.md index 0fc6e3a..4356d18 100644 --- a/.github/ISSUE_TEMPLATE/yummy-issue-template.md +++ b/.github/ISSUE_TEMPLATE/yummy-issue-template.md @@ -8,11 +8,9 @@ assignees: '' --- ## 📋 이슈 내용 - > 추가하려는 기능에 대해 간결하게 설명해주세요 ## ✅ 체크리스트 - - [ ] TODO - [ ] TODO - [ ] TODO diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 76feb13..836b893 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,5 +1,10 @@ -## 📋 이슈 번호 +## ⚡️ 관련 이슈 +- close #이슈번호 -## 🛠 구현 사항 +## 📍주요 변경 사항 +> 주요 변경 사항에 대해 작성해주세요. + + +## 🎸기타 +> 고려해야 하는 내용을 작성해 주세요. -## 📚 기타 diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..ee509c8 --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,64 @@ +name: Build and Deploy to EC2 + +on: + push: + branches: + - main + - dev + +jobs: + build-and-push-docker: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Cache Gradle packages + uses: gradle/actions/setup-gradle@v4 + + - name: Build with Gradle + run: ./gradlew clean build + +# - name: application.properties 파일 생성 +# run: echo "${{ secrets.APPLICATION_PROPERTIES }}" > ./application/src/main/resources/application.properties +# cat ./application/src/main/resources/application.properties + + - name: AWS Resource에 접근할 수 있게 AWS credentials 설정 + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-region: ap-northeast-2 + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID}} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY}} + + - name: ECR login + id: login-ecr + uses: aws-actions/amazon-ecr-login@v2 + + - name: Docker 이미지 생성 + run: docker build -t my-yummy . + + - name: Docker 이미지에 태그 붙이기 + run: docker tag my-yummy ${{ secrets.ECR_REGISTRY }}/my-yummy:latest + + - name: ECR에 Docker 이미지 Push + run: docker push ${{ secrets.ECR_REGISTRY }}/my-yummy:latest + + - name: SSH로 EC2에 접속하기 + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.EC2_HOST}} + username: ${{ secrets.EC2_USERNAME}} + key: ${{ secrets.EC2_PRIVATE_KEY}} + script_stop: true + script: | + cd ~/my-yummy + docker compose down || true + docker pull ${{ secrets.ECR_REGISTRY }}/my-yummy:latest + docker compose up -d --build \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..edcdf6a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +name: BE CI + +on: + pull_request: + branches: + - main + - dev +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Github Repository 파일 불러오기 + uses: actions/checkout@v4 + + - name: JDK 17 설정 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - name: 테스트 코드 실행 + run: ./gradlew test \ No newline at end of file diff --git a/.gitignore b/.gitignore index c2065bc..7a0f4d7 100644 --- a/.gitignore +++ b/.gitignore @@ -18,7 +18,7 @@ bin/ !**/src/test/**/bin/ ### IntelliJ IDEA ### -.idea +.idea/ *.iws *.iml *.ipr diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ff092ea --- /dev/null +++ b/Dockerfile @@ -0,0 +1,3 @@ +FROM openjdk:17-jdk +COPY application/build/libs/yummy_server.jar yummy_server.jar +ENTRYPOINT ["java", "-jar", "yummy_server.jar"] diff --git a/README.md b/README.md index 38198af..56c6d1a 100644 --- a/README.md +++ b/README.md @@ -1,77 +1,23 @@ -# famous_restaurant +# Yummy +> 지역 맛집 탐방 소모임 형성 서비스 -Re:quire's famous restaurant service rep +# 🚀 기능 +## 핵심 기능 +### 카카오 소셜 로그인 +### 실제 가게 정보 연동 API 구현 +### 소모임 생성 및 참가 +### 소모임 댓글 등록 ---- +## API 명세 +Screenshot 2025-01-24 at 2 42 43 PM -# 커밋 컨벤션 +# ⿳ ERD +맛집 소모임 ERD-white - - 예시 - - feat: hyungjun#12-feat-create-user-api - #2 +# 🏛️ 멀티 모듈 설계 및 흐름도 +사용성 기반 흐름도 +각 모듈 구성 및 흐름도 -- feat: 새로운 기능 추가 -- fix: 버그 수정 -- docs: 문서 수정 -- style: 코드 포맷팅, 세미콜론 누락, 코드 변경이 없는 경우 -- refactor: 코드 리팩토링 -- test: 테스트 코드, 리팩토링 테스트 코드 추가 -- chore: 빌드 업무 수정, 패키지 매니저 수정 +# 🗺️ 아키텍쳐 +아키텍쳐 -# 이슈 템플릿 - - - issue 제목 - - 예시: feat: 이슈 정리 - - issue 템플릿 - - ```markdown - ## 📋 이슈 내용 - - ## ✅ 체크리스트 - - ## 📚 레퍼런스 - - ``` - - 제목 예시 - - add: UI button 구현 - -# branch 규칙 - - - 각자 영어이름#이슈번호-이슈타입-이슈제목 - - 예시: hyungjun#12-feat-create-user-api - - - 종류: 메시지 - #이슈번호 - - 예시 - - feat: hyungjun#12-feat-create-user-api - #2 - -# PR 템플릿 - - - PR 템플릿 - - ```markdown - ## 📋 이슈 번호 - - ## 🛠 구현 사항 - - ## 📚 기타 - - ``` - -# merge 컨벤션 - - - merge: 브랜치 이름 - #Issue 번호 혹은 PR 번호 - - 예시 - - merge: main <-hyungjun#1-feat-user-controller - -# ERD - ---- -![맛집 소모임 ERD](https://github.com/user-attachments/assets/2442f3aa-67db-4933-af94-32f98a855d20) - - -# 🗺️아키텍처 - ---- - -# 💡기능 - ---- diff --git a/application/build.gradle b/application/build.gradle index 6eb68c3..7c5451b 100644 --- a/application/build.gradle +++ b/application/build.gradle @@ -1,7 +1,7 @@ plugins { id 'java' - id 'org.springframework.boot' version '3.4.1' - id 'io.spring.dependency-management' version '1.1.7' + id 'org.springframework.boot' + id 'io.spring.dependency-management' } group = 'com.groom' @@ -14,15 +14,53 @@ java { } dependencies { + // 모듈 간 의존성 + implementation project(':storage') + implementation project(':domain') + implementation project(':common') + + // Spring Boot Starters + implementation 'org.springframework.boot:spring-boot-starter' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-webflux' + + // OAuth2 + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + + // JWT + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + implementation 'io.jsonwebtoken:jjwt-impl:0.12.3' + implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3' + // Swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2' + + // Database + runtimeOnly 'com.h2database:h2' + + // Lombok + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + // Testing Dependencies testImplementation platform('org.junit:junit-bom:5.10.0') testImplementation 'org.junit.jupiter:junit-jupiter' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' + testImplementation 'javax.servlet:javax.servlet-api:4.0.1' - implementation 'org.springframework.boot:spring-boot-starter-web' - implementation 'org.springframework.boot:spring-boot-starter' + // Mocking and JSON Processing + testImplementation 'com.squareup.okhttp3:mockwebserver:4.11.0' + testImplementation 'org.mockito:mockito-core:5.6.0' + testImplementation 'org.mockito:mockito-junit-jupiter:5.7.0' + testImplementation 'com.fasterxml.jackson.core:jackson-databind:2.15.2' implementation project(':domain') + // Netty + implementation "io.netty:netty-resolver-dns-native-macos:4.1.110.Final:osx-x86_64" } test { @@ -30,6 +68,8 @@ test { } bootJar { + mainClass = 'com.groom.yummy.YummyApplication' + archiveFileName = "yummy_server.jar" enabled = true } diff --git a/application/src/main/java/com/groom/yummy/YummyApplication.java b/application/src/main/java/com/groom/yummy/YummyApplication.java new file mode 100644 index 0000000..6ed48a4 --- /dev/null +++ b/application/src/main/java/com/groom/yummy/YummyApplication.java @@ -0,0 +1,9 @@ +package com.groom.yummy; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication + public class YummyApplication { + public static void main(String[] args){ SpringApplication.run(YummyApplication.class, args); } +} diff --git a/application/src/main/java/com/groom/yummy/config/CorsMvcConfig.java b/application/src/main/java/com/groom/yummy/config/CorsMvcConfig.java new file mode 100644 index 0000000..f4240ea --- /dev/null +++ b/application/src/main/java/com/groom/yummy/config/CorsMvcConfig.java @@ -0,0 +1,20 @@ +package com.groom.yummy.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class CorsMvcConfig implements WebMvcConfigurer { + + @Value("${server.url}") + private String SERVER_URL; + + @Override + public void addCorsMappings(CorsRegistry corsRegistry){ + corsRegistry.addMapping("/**") + .exposedHeaders("Set-Cookie") + .allowedOrigins(SERVER_URL); + } +} diff --git a/application/src/main/java/com/groom/yummy/config/SecurityConfig.java b/application/src/main/java/com/groom/yummy/config/SecurityConfig.java new file mode 100644 index 0000000..5e14c14 --- /dev/null +++ b/application/src/main/java/com/groom/yummy/config/SecurityConfig.java @@ -0,0 +1,91 @@ +package com.groom.yummy.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.groom.yummy.exception.security.CustomAccessDeniedHandler; +import com.groom.yummy.exception.security.CustomAuthenticationEntryPoint; +import com.groom.yummy.filter.JwtAuthFilter; +import com.groom.yummy.oauth2.handler.CustomSuccessHandler; +import com.groom.yummy.oauth2.service.CustomOAuth2UserService; +import com.groom.yummy.jwt.JwtProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final CustomOAuth2UserService customOAuth2UserService; + private final CustomSuccessHandler customSuccessHandler; + private final JwtProvider jwtUtil; + private final ObjectMapper objectMapper; + + @Value("${server.url}") + private String SERVER_URL; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{ + //cors 설정 + http.cors((cors -> cors.configurationSource(configurationSource()))); + + // csfr disable + http.csrf((auth) -> auth.disable()); + + // form 로그인 disable + http.formLogin((auth) -> auth.disable()); + + // HTTP Basic 인증 방식 disable + http.httpBasic((auth) -> auth.disable()); + + + //경로별 인가 작업 + http.authorizeHttpRequests((auth) -> auth + .requestMatchers( "/login/**", "/oauth2/**", "/oauth2/authorization/**").permitAll() + .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-resources/**", "/webjars/**").permitAll() // Swagger 관련 경로 허용 + .requestMatchers("/api/v1/test/users/*").permitAll() + .requestMatchers("/api/v1/stores/*","/api/v1/stores").permitAll() + .anyRequest().authenticated()); + + //세션 설정 : STATELESS + http.sessionManagement((session) -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + + //oauth2 + http.oauth2Login((oauth2) -> + oauth2.userInfoEndpoint((userInfoEndpointConfig) -> userInfoEndpointConfig + .userService(customOAuth2UserService)).successHandler(customSuccessHandler)); + + // JWTFilter 추가 + http.addFilterBefore(new JwtAuthFilter(jwtUtil,objectMapper), UsernamePasswordAuthenticationFilter.class); + + // Exception handler 추가 + http.exceptionHandling(exceptionHandling -> + exceptionHandling + .accessDeniedHandler(new CustomAccessDeniedHandler(objectMapper)) + .authenticationEntryPoint(new CustomAuthenticationEntryPoint(objectMapper))); + return http.build(); + } + + public CorsConfigurationSource configurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.addAllowedHeader("*"); + configuration.addAllowedMethod("*"); + configuration.addAllowedOrigin(SERVER_URL); // 특정 도메인 허용 + configuration.addAllowedOrigin("http://13.124.191.4:8080"); // 특정 도메인 허용 + configuration.setAllowCredentials(true); + configuration.addExposedHeader("ACCESS_TOKEN"); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); // 모든 주소요청에 위 설정을 넣어주겠다. + return source; + } +} diff --git a/application/src/main/java/com/groom/yummy/config/SwaggerConfig.java b/application/src/main/java/com/groom/yummy/config/SwaggerConfig.java new file mode 100644 index 0000000..36dfeb8 --- /dev/null +++ b/application/src/main/java/com/groom/yummy/config/SwaggerConfig.java @@ -0,0 +1,40 @@ +package com.groom.yummy.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.servers.Server; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.ArrayList; +import java.util.List; + +@Configuration +public class SwaggerConfig { + + @Value("${server.url}") + private String SERVER_URL; + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .components(new Components()) + .info(apiInfo()) + .servers(servers()); + } + + private Info apiInfo() { + return new Info() + .title("yummy 명세서") + .description("api 명세서") + .version("1.0.0"); + } + + private List servers() { + List servers = new ArrayList<>(); + servers.add(new Server().url(SERVER_URL).description("YUMMY 명세서")); + return servers; + } +} diff --git a/application/src/main/java/com/groom/yummy/config/WebClientConfig.java b/application/src/main/java/com/groom/yummy/config/WebClientConfig.java new file mode 100755 index 0000000..73046c4 --- /dev/null +++ b/application/src/main/java/com/groom/yummy/config/WebClientConfig.java @@ -0,0 +1,14 @@ +package com.groom.yummy.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.client.WebClient; + +@Configuration +public class WebClientConfig { + @Bean + public WebClient webClient() { + return WebClient.builder().baseUrl("http://43.203.242.150:8080").build(); // 실제 환경에서는 동적으로 설정 + } +} + diff --git a/application/src/main/java/com/groom/yummy/controller/GroupController.java b/application/src/main/java/com/groom/yummy/controller/GroupController.java new file mode 100755 index 0000000..275c2ca --- /dev/null +++ b/application/src/main/java/com/groom/yummy/controller/GroupController.java @@ -0,0 +1,95 @@ +package com.groom.yummy.controller; + +import com.groom.yummy.dto.ResponseDto; +import com.groom.yummy.group.GroupService; +import com.groom.yummy.group.dto.request.CreateGroupRequestDto; +import com.groom.yummy.group.dto.request.JoinGroupRequestDto; +import com.groom.yummy.group.dto.response.GroupDetailResponseDto; +import com.groom.yummy.group.dto.response.GroupResponseDto; +import com.groom.yummy.oauth2.auth.LoginUser; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.stream.Collectors; + +@Tag(name = "[Group] Group API") +@RestController +@RequestMapping("/api/v1/groups") +@RequiredArgsConstructor +public class GroupController { + + private final GroupService groupService; + + @Operation(summary = "소모임 생성", description = "소모임을 생성합니다.") + @PostMapping + public ResponseEntity> createGroup( + @Valid @RequestBody CreateGroupRequestDto requestDto, + @AuthenticationPrincipal LoginUser loginUser + ) { + Long userId = loginUser.getUserId(); + Long groupId = groupService.createGroup( + requestDto.getStoreId(), + userId, + requestDto.getTitle(), + requestDto.getContent(), + requestDto.getMaxParticipants(), + requestDto.getMinParticipants(), + requestDto.getMeetingDate() + ); + + GroupResponseDto response = groupService.findGroupById(groupId) + .map(GroupResponseDto::fromGroupDomain) + .orElseThrow(); + + return ResponseEntity.status(HttpStatus.CREATED) + .body(new ResponseDto<>(response, "소모임 생성 성공")); + } + + @Operation(summary = "소모임 리스트 조회", description = "생성되어있는 소모임 리스트를 출력합니다.") + @GetMapping + public ResponseEntity>> getAllGroups( + @RequestParam(required = false) String category, + @RequestParam(required = false) String regionCode, + @RequestParam(required = false) String storeName, + @RequestParam(defaultValue = "1") int page + ) { + List groups = groupService.getAllGroups(category, regionCode, storeName, page) + .stream() + .map(GroupResponseDto::fromGroupDomain) + .collect(Collectors.toList()); + + return ResponseEntity.ok(new ResponseDto<>(groups, "소모임 목록 조회 성공")); + } + + @Operation(summary = "소모임 상세 정보 조회", description = "소모임 ID 기반으로 소모임의 상세 정보를 출력합니다.") + @GetMapping("/{groupId}") + public ResponseEntity> getGroupById( + @PathVariable Long groupId + ) { + GroupDetailResponseDto response = groupService.findGroupById(groupId) + .map(GroupDetailResponseDto::fromGroupDomain) + .orElseThrow(() -> new IllegalArgumentException("소모임을 찾을 수 없습니다.")); + + return ResponseEntity.ok(new ResponseDto<>(response, "소모임 상세 정보 조회 성공")); + } + + @Operation(summary = "소모임 참가", description = "선택한 소모임에 참가합니다.") + @PostMapping("/{groupId}/join") + public ResponseEntity> joinGroup( + @PathVariable Long groupId, + @Valid @RequestBody JoinGroupRequestDto requestDto, + @AuthenticationPrincipal LoginUser loginUser + ) { + Long userId = loginUser.getUserId(); + groupService.joinGroup(groupId, userId, requestDto.getStoreId()); + return ResponseEntity.ok(new ResponseDto<>(null, "소모임 참여 성공")); + } +} + diff --git a/application/src/main/java/com/groom/yummy/controller/ReplyController.java b/application/src/main/java/com/groom/yummy/controller/ReplyController.java new file mode 100644 index 0000000..69923e5 --- /dev/null +++ b/application/src/main/java/com/groom/yummy/controller/ReplyController.java @@ -0,0 +1,175 @@ +package com.groom.yummy.controller; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.groom.yummy.dto.ResponseDto; +import com.groom.yummy.reply.Reply; +import com.groom.yummy.reply.ReplyService; +import com.groom.yummy.reply.dto.ReplyRequestDto; +import com.groom.yummy.reply.dto.ReplyResponseDto; +import com.groom.yummy.reply.dto.ReplyUpdateRequestDto; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@Tag(name = "[Reply] Reply API") +@RestController +@RequestMapping("/api/v1/reply") +@RequiredArgsConstructor +public class ReplyController { + + private final ReplyService replyService; + + @Operation( + summary = "댓글 등록", + description = "댓글을 등록합니다.", + responses = { + @ApiResponse( + responseCode = "200", + description = "댓글 작성 성공", + content = @Content(schema = @Schema(implementation = ReplyResponseDto.class)) + ), + @ApiResponse( + responseCode = "400", + description = "잘못된 요청 데이터" + ) + } + ) + @PostMapping + public ResponseEntity registerReply(@Valid @RequestBody ReplyRequestDto request) { + try { + Reply savedReply = replyService.createReply(request.toDomain()); + ReplyResponseDto responseDto = ReplyResponseDto.fromDomain(savedReply); + return ResponseEntity.ok(new ResponseDto<>(responseDto, "댓글을 성공적으로 작성하였습니다.")); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(new ResponseDto<>(-1, "댓글 작성 실패: " + e.getMessage())); + } + } + + @Operation( + summary = "댓글 목록 조회", + description = "특정 그룹 ID에 속한 모든 댓글을 페이지 단위로 조회합니다.", + parameters = { + @Parameter(name = "groupId", description = "그룹 ID", required = true), + @Parameter(name = "page", description = "조회할 페이지 번호 (0부터 시작)", example = "0"), + @Parameter(name = "size", description = "페이지 크기", example = "10") + }, + responses = { + @ApiResponse( + responseCode = "200", + description = "댓글 조회 성공", + content = @Content(schema = @Schema(implementation = ReplyResponseDto.class)) + ), + @ApiResponse( + responseCode = "400", + description = "잘못된 요청 데이터" + ) + } + ) + @GetMapping(path = "/all") + public ResponseEntity getAllReplies( + @RequestParam Long groupId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size) { + try { + PageRequest pageRequest = PageRequest.of(page, size); + Page replies = replyService.getAllReplies(groupId, pageRequest); + Page responseDtos = replies.map(ReplyResponseDto::fromDomain); + return ResponseEntity.ok(new ResponseDto<>(responseDtos, "댓글 조회 성공")); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(new ResponseDto<>(-1, "댓글 조회 실패: " + e.getMessage())); + } + } + + @Operation( + summary = "댓글 수정", + description = "댓글의 내용을 수정합니다.", + parameters = { + @Parameter(name = "id", description = "수정할 댓글 ID", required = true) + }, + responses = { + @ApiResponse( + responseCode = "200", + description = "댓글 수정 성공", + content = @Content(schema = @Schema(implementation = ReplyResponseDto.class)) + ), + @ApiResponse( + responseCode = "400", + description = "잘못된 요청 데이터 또는 댓글이 존재하지 않음" + ), + @ApiResponse( + responseCode = "500", + description = "서버 오류" + ) + } + ) + @PatchMapping("/{id}") + public ResponseEntity updateReply(@PathVariable Long id, @Valid @RequestBody ReplyUpdateRequestDto request) { + try { + Reply updatedReply = replyService.updateReply(id, request.content()); + ReplyResponseDto responseDto = ReplyResponseDto.fromDomain(updatedReply); + return ResponseEntity.ok(new ResponseDto<>(responseDto, "댓글이 성공적으로 수정되었습니다.")); + } catch (IllegalArgumentException e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(new ResponseDto<>(-1, "댓글 수정 실패: " + e.getMessage())); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ResponseDto<>(-1, "서버 오류: " + e.getMessage())); + } + } + + @Operation( + summary = "댓글 삭제", + description = "댓글을 삭제합니다.", + parameters = { + @Parameter(name = "id", description = "삭제할 댓글 ID", required = true) + }, + responses = { + @ApiResponse( + responseCode = "200", + description = "댓글 삭제 성공" + ), + @ApiResponse( + responseCode = "400", + description = "잘못된 요청 데이터 또는 댓글 삭제 실패" + ), + @ApiResponse( + responseCode = "500", + description = "서버 오류" + ) + } + ) + @DeleteMapping("/{id}") + public ResponseEntity deleteReply(@PathVariable Long id) { + try { + replyService.deleteReply(id); + return ResponseEntity.ok(new ResponseDto<>(null, "댓글이 성공적으로 삭제되었습니다.")); + } catch (IllegalStateException e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(new ResponseDto<>(-1, "댓글 삭제 실패: " + e.getMessage())); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ResponseDto<>(-1, "서버 오류: " + e.getMessage())); + } + } +} diff --git a/application/src/main/java/com/groom/yummy/controller/StoreController.java b/application/src/main/java/com/groom/yummy/controller/StoreController.java new file mode 100644 index 0000000..3550275 --- /dev/null +++ b/application/src/main/java/com/groom/yummy/controller/StoreController.java @@ -0,0 +1,43 @@ +package com.groom.yummy.controller; + +import com.groom.yummy.dto.ResponseDto; +import com.groom.yummy.external.Category; +import com.groom.yummy.external.StoreApiClient; +import com.groom.yummy.external.dto.ApiResponse; +import com.groom.yummy.external.dto.StoreListResponse; +import com.groom.yummy.external.dto.StoreResponseDto; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "[Store] Store API") +@RestController +@RequestMapping("/api/v1/stores") +@RequiredArgsConstructor +public class StoreController { + private final StoreApiClient storeApiClient; + + @Operation(summary = "가게 정보 조회", description = "가게 id로 가게를 조회합니다.") + @GetMapping("/{storeId}") + public ResponseEntity> getStore(@PathVariable("storeId") Long storeId) { + StoreResponseDto storeResponseDTO = storeApiClient.getStoreByApi(storeId); + return ResponseEntity.ok(ResponseDto.of(storeResponseDTO,"storeId로 가게 조회 성공")); + } + + @Operation(summary = "가게 조회", description = "가게들을 정렬 기준에 맞게 조회합니다.") + @GetMapping + public ResponseEntity> getStoresByFilters( + @RequestParam(name = "category", required = false) Category category, + @RequestParam(name = "regionId", required = false) Long regionId, + @RequestParam(name = "name", required = false) String name, + @RequestParam(name = "page", defaultValue = "1") int page, + @RequestParam(name = "size", defaultValue = "10") int size) { + ApiResponse storeListResponseApiResponse = storeApiClient.getStoresByFilters(category, regionId,name,page,size); + return ResponseEntity.ok(storeListResponseApiResponse); + + } +} + + diff --git a/application/src/main/java/com/groom/yummy/controller/UserController.java b/application/src/main/java/com/groom/yummy/controller/UserController.java new file mode 100644 index 0000000..7f2ec7b --- /dev/null +++ b/application/src/main/java/com/groom/yummy/controller/UserController.java @@ -0,0 +1,56 @@ +package com.groom.yummy.controller; + +import com.groom.yummy.dto.ResponseDto; +import com.groom.yummy.user.dto.request.UpdateNicknameReqDto; +import com.groom.yummy.user.dto.response.UserInfoResDto; +import com.groom.yummy.user.facade.UserFacade; +import com.groom.yummy.oauth2.auth.LoginUser; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "[User] User API") +@RestController +@RequiredArgsConstructor +@Slf4j +@RequestMapping("/api/v1/users") +public class UserController { + private final UserFacade userFacade; + + @Operation(summary = "자신의 정보 조회", description = "토큰 정보 기반 유저 정보를 조회합니다") + @GetMapping() + public ResponseEntity> getUserInfoByToken(@AuthenticationPrincipal LoginUser loginUser){ + Long userId = loginUser.getUserId(); + UserInfoResDto userInfoResDto = userFacade.getUserInfo(userId); + return ResponseEntity.ok(ResponseDto.of(userInfoResDto,"자신의 정보 조회 성공")); + } + + @Operation(summary = "유저 정보 조회", description = "토큰 정보 기반 유저 정보를 조회합니다") + @GetMapping("/{userId}") + public ResponseEntity> getUserInfo(@PathVariable("userId") Long userId){ + UserInfoResDto userInfoResDto = userFacade.getUserInfo(userId); + return ResponseEntity.ok(ResponseDto.of(userInfoResDto,"유저 정보 조회 성공")); + } + + @Operation(summary = "유저 닉네임 변경", description = "토큰 정보 기반 유저 닉네임을 변경합니다.") + @PatchMapping("/profile") + public ResponseEntity> updateUserNickname(@Valid @RequestBody UpdateNicknameReqDto updateNicknameReqDto, + @AuthenticationPrincipal LoginUser loginUser){ + Long userId = loginUser.getUserId(); + UserInfoResDto userInfoResDto = userFacade.updateUserNickname(userId,updateNicknameReqDto); + return ResponseEntity.ok(ResponseDto.of(userInfoResDto,"유저 닉네임 변경 성공")); + } + + @Operation(summary = "유저 계정 삭제", description = "토큰 정보 기반 유저를 삭제합니다.") + @DeleteMapping() + public ResponseEntity> deleteUserByToken(@AuthenticationPrincipal LoginUser loginUser){ + Long userId = loginUser.getUserId(); + Long deleteUserId = userFacade.deleteUser(userId); + return ResponseEntity.ok(ResponseDto.of(deleteUserId,"회원정보 삭제 성공")); + } +} diff --git a/application/src/main/java/com/groom/yummy/exception/JwtErrorCode.java b/application/src/main/java/com/groom/yummy/exception/JwtErrorCode.java new file mode 100644 index 0000000..1be3c08 --- /dev/null +++ b/application/src/main/java/com/groom/yummy/exception/JwtErrorCode.java @@ -0,0 +1,20 @@ +package com.groom.yummy.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public enum JwtErrorCode implements ErrorCode { + WRONG_TYPE_TOKEN(HttpStatus.UNAUTHORIZED,"토큰의 서명이 유효하지 않습니다."), + UNSUPPORTED_TOKEN(HttpStatus.UNAUTHORIZED,"잘못된 형식의 토큰입니다."), + EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED,"만료된 토큰입니다."), + UNKNOWN_TOKEN_ERROR(HttpStatus.BAD_REQUEST,"토큰의 값이 존재하지 않습니다."), + ; + private final HttpStatus code; + private final String message; + + JwtErrorCode(HttpStatus code, String message) { + this.code = code; + this.message = message; + } +} diff --git a/application/src/main/java/com/groom/yummy/exception/handler/RestControllerExceptionHandler.java b/application/src/main/java/com/groom/yummy/exception/handler/RestControllerExceptionHandler.java new file mode 100644 index 0000000..49c3449 --- /dev/null +++ b/application/src/main/java/com/groom/yummy/exception/handler/RestControllerExceptionHandler.java @@ -0,0 +1,46 @@ +package com.groom.yummy.exception.handler; + +import com.groom.yummy.dto.ResponseDto; +import com.groom.yummy.exception.CustomException; +import io.swagger.v3.oas.annotations.Hidden; +import jakarta.validation.ConstraintViolationException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.BindingResult; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@Slf4j +@Hidden +@RestControllerAdvice +public class RestControllerExceptionHandler { + + /* 컨트롤러 메서드의 파라미터나 엔터티 필드에서 유효성 검사가 실패 */ + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity> handleConstraintViolationException(ConstraintViolationException ex){ + log.error("유효성 검사 예외 발생 msg: {}", ex.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ResponseDto.of(-1, ex.getMessage())); + } + + /* @RequestBody 로 들어오는 JSON 요청 객체의 필드 값이 유효성 검사에 실패 */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleMethodArgumentNotValidException(MethodArgumentNotValidException ex){ + BindingResult bindingResult = ex.getBindingResult(); + FieldError fieldError = bindingResult.getFieldError(); + String message = fieldError.getDefaultMessage(); + log.error("유효성 검사 예외 발생 msg:{}",message); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ResponseDto.of(-1,message)); + } + + /* 클라이언트에서 잘못된 JSON 형식의 요청을 보낼 때 발생 */ + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity> handleHttpMessageNotReadableException(HttpMessageNotReadableException ex) { + String errorMessage = "요청한 JSON 데이터를 읽을 수 없습니다: " + ex.getMessage(); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ResponseDto.of(-1,errorMessage)); + } +} + diff --git a/application/src/main/java/com/groom/yummy/exception/security/CustomAccessDeniedHandler.java b/application/src/main/java/com/groom/yummy/exception/security/CustomAccessDeniedHandler.java new file mode 100644 index 0000000..ff257de --- /dev/null +++ b/application/src/main/java/com/groom/yummy/exception/security/CustomAccessDeniedHandler.java @@ -0,0 +1,26 @@ +package com.groom.yummy.exception.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.groom.yummy.dto.ResponseDto; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class CustomAccessDeniedHandler implements AccessDeniedHandler { + private final ObjectMapper objectMapper; + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { + response.setContentType("application/json;charset=UTF-8"); + String result = objectMapper.writeValueAsString(ResponseDto.of(403,"접근 권한이 없는 사용자입니다.")); + response.setStatus(403); + response.getWriter().write(result); + } +} \ No newline at end of file diff --git a/application/src/main/java/com/groom/yummy/exception/security/CustomAuthenticationEntryPoint.java b/application/src/main/java/com/groom/yummy/exception/security/CustomAuthenticationEntryPoint.java new file mode 100644 index 0000000..4977aae --- /dev/null +++ b/application/src/main/java/com/groom/yummy/exception/security/CustomAuthenticationEntryPoint.java @@ -0,0 +1,26 @@ +package com.groom.yummy.exception.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.groom.yummy.dto.ResponseDto; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { + private final ObjectMapper objectMapper; + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { + response.setContentType("application/json;charset=UTF-8"); + String result = objectMapper.writeValueAsString(ResponseDto.of(401,"인증이 실패하였습니다.")); + response.setStatus(401); + response.getWriter().write(result); + } +} \ No newline at end of file diff --git a/application/src/main/java/com/groom/yummy/external/Category.java b/application/src/main/java/com/groom/yummy/external/Category.java new file mode 100644 index 0000000..9b20632 --- /dev/null +++ b/application/src/main/java/com/groom/yummy/external/Category.java @@ -0,0 +1,27 @@ +package com.groom.yummy.external; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public enum Category { + + CHICKEN("치킨"), + CHINESE("중식"), + CUTLET_SASHIMI("돈까스-회"), + PIZZA("피자"), + FAST_FOOD("패스트푸드"), + STEW_SOUP("찜-탕"), + JOKBAL_BOSSAM("족발-보쌈"), + SNACK("분식"), + CAFE_DESSERT("카페-디저트"), + KOREAN("한식"), + MEAT("고기"), + WESTERN("양식"), + ASIAN("아시안"), + LATE_NIGHT("야식"), + LUNCH_BOX("도시락"); + private final String description; + +} diff --git a/application/src/main/java/com/groom/yummy/external/StoreApiClient.java b/application/src/main/java/com/groom/yummy/external/StoreApiClient.java new file mode 100644 index 0000000..093d841 --- /dev/null +++ b/application/src/main/java/com/groom/yummy/external/StoreApiClient.java @@ -0,0 +1,43 @@ +package com.groom.yummy.external; + +import com.groom.yummy.external.dto.ApiResponse; +import com.groom.yummy.external.dto.StoreListResponse; +import com.groom.yummy.external.dto.StoreResponseDto; + +import lombok.RequiredArgsConstructor; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; + +@Service +@RequiredArgsConstructor +public class StoreApiClient { + private final WebClient webClient; + + public StoreResponseDto getStoreByApi(Long storeId){ + StoreResponseDto storeResponse = webClient.get() + .uri(uriBuilder -> uriBuilder.path("/api/v1/store/{id}") + .build(storeId)) + .retrieve() + .bodyToMono(new ParameterizedTypeReference>() {}) + .block().data(); + + return storeResponse; + } + + public ApiResponse getStoresByFilters( + Category category, Long regionId, String name, int page, int size) { + return webClient.get() + .uri(uriBuilder -> uriBuilder + .path("/api/v1/store") + .queryParam("category", category) + .queryParam("regionId", regionId) + .queryParam("name", name) + .queryParam("page", page) + .queryParam("size", size) + .build()) + .retrieve() + .bodyToMono(new ParameterizedTypeReference>() {}) + .block(); + } +} diff --git a/application/src/main/java/com/groom/yummy/external/dto/ApiResponse.java b/application/src/main/java/com/groom/yummy/external/dto/ApiResponse.java new file mode 100644 index 0000000..1177aa8 --- /dev/null +++ b/application/src/main/java/com/groom/yummy/external/dto/ApiResponse.java @@ -0,0 +1,6 @@ +package com.groom.yummy.external.dto; + +public record ApiResponse( + T data, + String message +) {} diff --git a/application/src/main/java/com/groom/yummy/external/dto/PaginationInfo.java b/application/src/main/java/com/groom/yummy/external/dto/PaginationInfo.java new file mode 100644 index 0000000..36de235 --- /dev/null +++ b/application/src/main/java/com/groom/yummy/external/dto/PaginationInfo.java @@ -0,0 +1,9 @@ +package com.groom.yummy.external.dto; + +public record PaginationInfo( + int page, + int size, + int totalPages, + long totalElements +) { +} diff --git a/application/src/main/java/com/groom/yummy/external/dto/StoreListResponse.java b/application/src/main/java/com/groom/yummy/external/dto/StoreListResponse.java new file mode 100644 index 0000000..951a967 --- /dev/null +++ b/application/src/main/java/com/groom/yummy/external/dto/StoreListResponse.java @@ -0,0 +1,9 @@ +package com.groom.yummy.external.dto; + +import java.util.List; + + +public record StoreListResponse( + List stores, + PaginationInfo pageable +) {} diff --git a/application/src/main/java/com/groom/yummy/external/dto/StoreRequestDto.java b/application/src/main/java/com/groom/yummy/external/dto/StoreRequestDto.java new file mode 100644 index 0000000..1cc8ea0 --- /dev/null +++ b/application/src/main/java/com/groom/yummy/external/dto/StoreRequestDto.java @@ -0,0 +1,14 @@ +package com.groom.yummy.external.dto; + + +import lombok.Builder; +import com.groom.yummy.external.Category; + + +@Builder +public record StoreRequestDto( + String name, + Category category, + Long regionId +) { +} diff --git a/application/src/main/java/com/groom/yummy/external/dto/StoreResponseDto.java b/application/src/main/java/com/groom/yummy/external/dto/StoreResponseDto.java new file mode 100644 index 0000000..5fd39e1 --- /dev/null +++ b/application/src/main/java/com/groom/yummy/external/dto/StoreResponseDto.java @@ -0,0 +1,13 @@ +package com.groom.yummy.external.dto; + + +import lombok.Builder; + +@Builder +public record StoreResponseDto( + Long id, + String name, + String category, + Long regionId +) { +} diff --git a/application/src/main/java/com/groom/yummy/filter/JwtAuthFilter.java b/application/src/main/java/com/groom/yummy/filter/JwtAuthFilter.java new file mode 100644 index 0000000..572cc28 --- /dev/null +++ b/application/src/main/java/com/groom/yummy/filter/JwtAuthFilter.java @@ -0,0 +1,120 @@ +package com.groom.yummy.filter; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.groom.yummy.dto.ResponseDto; +import com.groom.yummy.exception.CustomException; +import com.groom.yummy.exception.ErrorCode; +import com.groom.yummy.exception.JwtErrorCode; +import com.groom.yummy.oauth2.auth.LoginUser; +import com.groom.yummy.user.User; +import com.groom.yummy.jwt.JwtProvider; +import io.jsonwebtoken.Jwt; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +import static com.groom.yummy.exception.JwtErrorCode.*; + +@Log4j2 +@Component +@RequiredArgsConstructor +public class JwtAuthFilter extends OncePerRequestFilter { + + private final JwtProvider jwtProvider; + private final ObjectMapper objectMapper; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + try{ + String token = findToken(request); + if (!verifyToken(request, token)) { + filterChain.doFilter(request, response); + return; + } + + User user = getUser(token); + setSecuritySession(user); + filterChain.doFilter(request, response); + + }catch (CustomException e){ + JwtErrorCode jwtErrorCode = (JwtErrorCode) e.getErrorCode(); + switch (jwtErrorCode) { + case WRONG_TYPE_TOKEN, UNSUPPORTED_TOKEN, EXPIRED_TOKEN, UNKNOWN_TOKEN_ERROR -> + setResponse(response, jwtErrorCode); + default -> { + log.error("알 수 없는 에러 코드: {}", jwtErrorCode); + setResponse(response, UNKNOWN_TOKEN_ERROR); // 기본 예외 처리 + } + } + } + } + + protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { + String path = request.getRequestURI(); + return path.startsWith("/swagger-ui/") || path.startsWith("/v3/api-docs") || path.startsWith("/swagger-resources/"); + } + + private static void setSecuritySession(User user){ + LoginUser loginUser = new LoginUser(user); + log.info("SessionLoginUser : {}", loginUser.getUsername()); + log.info("SessionLoginUser getAuthorities: {}", loginUser.getAuthorities()); + Authentication authToken = new UsernamePasswordAuthenticationToken(loginUser,null, loginUser.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(authToken); + } + + private User getUser(String token){ + Long userId = jwtProvider.getUserId(token); + String username = jwtProvider.getUsername(token); + String name = jwtProvider.getName(token); + String role = jwtProvider.getRole(token); + + log.info("getUser username: ", username); + return User.builder() + .id(userId) + .email(username) + .nickname(name) + .role(role) + .build(); + } + + private boolean verifyToken(HttpServletRequest request,String token) throws IOException, ServletException { + Boolean isValid = (Boolean) request.getAttribute("isTokenValid"); + if(isValid != null) return isValid; + + if (token == null || jwtProvider.validateToken(token)) { + log.debug("token null"); + request.setAttribute("isTokenValid",false); + return false; + } + + request.setAttribute("isTokenValid", true); + return true; + } + + private static String findToken(HttpServletRequest request){ + String token = null; + Cookie[] cookies = request.getCookies(); + for(Cookie cookie : cookies){ + if(cookie.getName().equals("Authorization")){ + token = cookie.getValue(); + } + } + return token; + } + private void setResponse(HttpServletResponse response, JwtErrorCode jwtErrorCode) throws IOException { + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(jwtErrorCode.getCode().value()); + response.getWriter().print(objectMapper.writeValueAsString(ResponseDto.of(-1,jwtErrorCode.getMessage()))); + } +} \ No newline at end of file diff --git a/application/src/main/java/com/groom/yummy/jwt/JwtProvider.java b/application/src/main/java/com/groom/yummy/jwt/JwtProvider.java new file mode 100644 index 0000000..bd75bcb --- /dev/null +++ b/application/src/main/java/com/groom/yummy/jwt/JwtProvider.java @@ -0,0 +1,84 @@ +package com.groom.yummy.jwt; + +import com.groom.yummy.exception.CustomException; +import com.groom.yummy.exception.JwtErrorCode; +import io.jsonwebtoken.*; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +@Component +@Slf4j +public class JwtProvider { + private SecretKey secretKey; + + @Value("${spring.jwt.valid-time}") + public Long VALID_TIME; + + @Value("${spring.jwt.cookie-name}") + public String COOKIE_NAME; + + private JwtProvider(@Value("${spring.jwt.secret}") String secret){ + secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm()); + } + + public String createAccessToken(Long userId, String email, String nickname, String role){ + Date timeNow = new Date(System.currentTimeMillis()); + Date expirationTime = new Date(timeNow.getTime() + VALID_TIME); + + return Jwts.builder() + .claim("userId", userId) + .claim("email",email) + .claim("nickname", nickname) + .claim("role",role) + .setIssuedAt(timeNow) + .setExpiration(expirationTime) + .signWith(secretKey, SignatureAlgorithm.HS256) + .compact(); + } + + public Long getUserId(String token) { + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("userId", Long.class); + } + public String getUsername(String token) { + + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("email", String.class); + } + + public String getName(String token){ + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("nickname", String.class); + } + + public String getRole(String token) { + + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("role", String.class); + } + + public boolean validateToken(String token){ + //log.info("토큰 유효성 검증 시작"); + return valid(secretKey, token); + } + + private boolean valid(SecretKey secretKey, String token){ + try{ + Jws claims = Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token); + return claims.getBody().getExpiration().before(new Date()); + }catch (SignatureException ex){ + throw new CustomException(JwtErrorCode.WRONG_TYPE_TOKEN); + }catch (MalformedJwtException ex){ + throw new CustomException(JwtErrorCode.UNSUPPORTED_TOKEN); + }catch (ExpiredJwtException ex){ + throw new CustomException(JwtErrorCode.EXPIRED_TOKEN); + }catch (IllegalArgumentException ex){ + throw new CustomException(JwtErrorCode.WRONG_TYPE_TOKEN); + } + } + +} + + diff --git a/application/src/main/java/com/groom/yummy/oauth2/auth/LoginUser.java b/application/src/main/java/com/groom/yummy/oauth2/auth/LoginUser.java new file mode 100644 index 0000000..b443319 --- /dev/null +++ b/application/src/main/java/com/groom/yummy/oauth2/auth/LoginUser.java @@ -0,0 +1,56 @@ +package com.groom.yummy.oauth2.auth; + +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.core.user.OAuth2User; +import com.groom.yummy.user.User; + +import java.util.Collection; +import java.util.Map; + +@Getter +public class LoginUser implements UserDetails, OAuth2User{ + private final User user; + private Map attributes; + + public LoginUser(User user){ + this.user = user; + } + public LoginUser(User user, Map attributes) { + this.user = user; + this.attributes = attributes; + } + + @Override + public Map getAttributes() { + return attributes; + } + + @Override + public Collection getAuthorities() { + return null; + } + + + @Override + public String getUsername() { + return user.getEmail(); + } + + @Override + public String getName() { + return user.getNickname(); + } + + public String getRole(){ + return user.getRole(); + } + + public Long getUserId(){return user.getId();} + + @Override + public String getPassword() { + return null; + } +} \ No newline at end of file diff --git a/application/src/main/java/com/groom/yummy/oauth2/dto/KakaoResponse.java b/application/src/main/java/com/groom/yummy/oauth2/dto/KakaoResponse.java new file mode 100644 index 0000000..1ef3b63 --- /dev/null +++ b/application/src/main/java/com/groom/yummy/oauth2/dto/KakaoResponse.java @@ -0,0 +1,33 @@ +package com.groom.yummy.oauth2.dto; + + +import java.util.Map; + +public class KakaoResponse implements OAuth2Response { + + private final Map attributes; + + public KakaoResponse(Map attributes) { + this.attributes = attributes; + } + + @Override + public String getProvider() { + return "kakao"; + } + + @Override + public String getProviderId() { + return attributes.get("id").toString(); + } + + @Override + public String getEmail() { + return ((Map)attributes.get("kakao_account")).get("email").toString(); + } + + @Override + public String getName() { + return ((Map)attributes.get("properties")).get("nickname").toString(); + } +} diff --git a/application/src/main/java/com/groom/yummy/oauth2/dto/OAuth2Response.java b/application/src/main/java/com/groom/yummy/oauth2/dto/OAuth2Response.java new file mode 100644 index 0000000..8bf578c --- /dev/null +++ b/application/src/main/java/com/groom/yummy/oauth2/dto/OAuth2Response.java @@ -0,0 +1,8 @@ +package com.groom.yummy.oauth2.dto; + +public interface OAuth2Response { + String getProvider(); + String getProviderId(); + String getEmail(); + String getName(); +} diff --git a/application/src/main/java/com/groom/yummy/oauth2/handler/CustomSuccessHandler.java b/application/src/main/java/com/groom/yummy/oauth2/handler/CustomSuccessHandler.java new file mode 100644 index 0000000..98d50e4 --- /dev/null +++ b/application/src/main/java/com/groom/yummy/oauth2/handler/CustomSuccessHandler.java @@ -0,0 +1,43 @@ +package com.groom.yummy.oauth2.handler; + +import com.groom.yummy.oauth2.auth.LoginUser; +import com.groom.yummy.util.CookieUtil; +import com.groom.yummy.jwt.JwtProvider; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +@Slf4j +public class CustomSuccessHandler implements AuthenticationSuccessHandler { + + private final JwtProvider jwtProvider; + + @Value("${server.url}") + private String SERVER_URL; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { + LoginUser loginUser = (LoginUser) authentication.getPrincipal(); + log.info("onAuthenticationSuccess Authenticated User: " + loginUser.getName()); + String email = loginUser.getUsername(); + String nickname = loginUser.getName(); + Long userId = loginUser.getUserId(); + String role = loginUser.getRole(); + + String accessToken = jwtProvider.createAccessToken(userId,email,nickname,role); + + response.addCookie(CookieUtil.createCookie(jwtProvider.COOKIE_NAME,accessToken, jwtProvider.VALID_TIME)); + response.sendRedirect(SERVER_URL+"/swagger-ui/index.html"); + } +} \ No newline at end of file diff --git a/application/src/main/java/com/groom/yummy/oauth2/service/CustomOAuth2UserService.java b/application/src/main/java/com/groom/yummy/oauth2/service/CustomOAuth2UserService.java new file mode 100644 index 0000000..7e52e96 --- /dev/null +++ b/application/src/main/java/com/groom/yummy/oauth2/service/CustomOAuth2UserService.java @@ -0,0 +1,46 @@ +package com.groom.yummy.oauth2.service; + +import com.groom.yummy.user.facade.UserFacade; +import com.groom.yummy.oauth2.auth.LoginUser; +import com.groom.yummy.oauth2.dto.OAuth2Response; +import com.groom.yummy.oauth2.strategy.OAuth2ResFactory; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; +import com.groom.yummy.user.User; + +import java.util.Optional; + +@Service +@Slf4j +public class CustomOAuth2UserService extends DefaultOAuth2UserService { + + private final OAuth2ResFactory oAuth2ResFactory; + private final UserFacade userFacade; + + public CustomOAuth2UserService(OAuth2ResFactory oAuth2ResFactory, UserFacade userFacade) { + this.oAuth2ResFactory = oAuth2ResFactory; + this.userFacade = userFacade; + } + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException{ + OAuth2User oAuth2User = super.loadUser(userRequest); + log.info("oAuth2User key-set : {}", oAuth2User.getAttributes().keySet()); + System.out.println("ID: " + oAuth2User.getAttributes().get("id")); + System.out.println("Connected At: " + oAuth2User.getAttributes().get("connected_at")); + String registrationId = userRequest.getClientRegistration().getRegistrationId(); + + OAuth2Response oAuth2Response = oAuth2ResFactory.createOAuth2Response(registrationId, oAuth2User.getAttributes()); + String email = oAuth2Response.getEmail(); + String nickname = oAuth2Response.getName(); + + Optional optionalUser = userFacade.findAuthUserByEmail(email); + User user = userFacade.findOrCreateUser(optionalUser, nickname, email); + log.info("loadUser : {}", user.getEmail()); + return new LoginUser(user, oAuth2User.getAttributes()); + } +} \ No newline at end of file diff --git a/application/src/main/java/com/groom/yummy/oauth2/strategy/KakaoOAuth2ResStrategy.java b/application/src/main/java/com/groom/yummy/oauth2/strategy/KakaoOAuth2ResStrategy.java new file mode 100644 index 0000000..f2f8a9d --- /dev/null +++ b/application/src/main/java/com/groom/yummy/oauth2/strategy/KakaoOAuth2ResStrategy.java @@ -0,0 +1,21 @@ +package com.groom.yummy.oauth2.strategy; + +import com.groom.yummy.oauth2.dto.KakaoResponse; +import com.groom.yummy.oauth2.dto.OAuth2Response; +import org.springframework.stereotype.Component; + +import java.util.Map; + +@Component +public class KakaoOAuth2ResStrategy implements OAuth2ResStrategy{ + + @Override + public String getProviderName() { + return "kakao"; + } + + @Override + public OAuth2Response createOAuth2Response(Map attributes) { + return new KakaoResponse(attributes); + } +} diff --git a/application/src/main/java/com/groom/yummy/oauth2/strategy/OAuth2ResFactory.java b/application/src/main/java/com/groom/yummy/oauth2/strategy/OAuth2ResFactory.java new file mode 100644 index 0000000..8473f9e --- /dev/null +++ b/application/src/main/java/com/groom/yummy/oauth2/strategy/OAuth2ResFactory.java @@ -0,0 +1,26 @@ +package com.groom.yummy.oauth2.strategy; + +import com.groom.yummy.oauth2.dto.OAuth2Response; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Component +public class OAuth2ResFactory { + private final Map strategies; + + public OAuth2ResFactory(List strategiyList) { + this.strategies = strategiyList.stream() + .collect(Collectors.toMap(OAuth2ResStrategy::getProviderName, Function.identity())); + } + + public OAuth2Response createOAuth2Response(String registrationId, Map attributes){ + OAuth2ResStrategy strategy = strategies.get(registrationId); + System.out.println(strategy); + if(Objects.equals(strategy,null)) throw new IllegalStateException("지원하지 않는 provider 입니다."); + return strategy.createOAuth2Response(attributes); + }} diff --git a/application/src/main/java/com/groom/yummy/oauth2/strategy/OAuth2ResStrategy.java b/application/src/main/java/com/groom/yummy/oauth2/strategy/OAuth2ResStrategy.java new file mode 100644 index 0000000..b10d05a --- /dev/null +++ b/application/src/main/java/com/groom/yummy/oauth2/strategy/OAuth2ResStrategy.java @@ -0,0 +1,10 @@ +package com.groom.yummy.oauth2.strategy; + +import com.groom.yummy.oauth2.dto.OAuth2Response; + +import java.util.Map; + +public interface OAuth2ResStrategy { + String getProviderName(); + OAuth2Response createOAuth2Response(Map attributes); +} diff --git a/application/src/main/java/com/groom/yummy/reply/dto/ReplyRequestDto.java b/application/src/main/java/com/groom/yummy/reply/dto/ReplyRequestDto.java new file mode 100644 index 0000000..180f52d --- /dev/null +++ b/application/src/main/java/com/groom/yummy/reply/dto/ReplyRequestDto.java @@ -0,0 +1,30 @@ +package com.groom.yummy.reply.dto; + +import com.groom.yummy.reply.Reply; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record ReplyRequestDto( + @NotNull(message = "댓글 내용은 필수입니다.") + @Size(max = 500, message = "댓글 내용은 500자를 넘을 수 없습니다.") + String content, + + Long parentId, + + @NotNull(message = "작성자 ID는 필수입니다.") + Long userId, + + @NotNull(message = "그룹 ID는 필수입니다.") + Long groupId +) { + + public Reply toDomain() { + return Reply.builder() + .content(content) + .parentReplyId(parentId) + .userId(userId) + .groupId(groupId) + .build(); + } +} \ No newline at end of file diff --git a/application/src/main/java/com/groom/yummy/reply/dto/ReplyResponseDto.java b/application/src/main/java/com/groom/yummy/reply/dto/ReplyResponseDto.java new file mode 100644 index 0000000..e6f55d7 --- /dev/null +++ b/application/src/main/java/com/groom/yummy/reply/dto/ReplyResponseDto.java @@ -0,0 +1,31 @@ +package com.groom.yummy.reply.dto; + +import java.time.LocalDateTime; + +import com.groom.yummy.reply.Reply; + +import lombok.Builder; + +@Builder +public record ReplyResponseDto( + Long id, + String content, + Long parentId, + Long userId, + Long groupId, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { + + public static ReplyResponseDto fromDomain(Reply reply) { + return ReplyResponseDto.builder() + .id(reply.getId()) + .content(reply.getContent()) + .parentId(reply.getParentReplyId()) + .userId(reply.getUserId()) + .groupId(reply.getGroupId()) + .createdAt(reply.getCreatedAt()) + .updatedAt(reply.getUpdatedAt()) + .build(); + } +} \ No newline at end of file diff --git a/application/src/main/java/com/groom/yummy/reply/dto/ReplyUpdateRequestDto.java b/application/src/main/java/com/groom/yummy/reply/dto/ReplyUpdateRequestDto.java new file mode 100644 index 0000000..88d8b00 --- /dev/null +++ b/application/src/main/java/com/groom/yummy/reply/dto/ReplyUpdateRequestDto.java @@ -0,0 +1,13 @@ +package com.groom.yummy.reply.dto; + +import com.groom.yummy.reply.Reply; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record ReplyUpdateRequestDto( + @NotNull(message = "댓글 내용은 필수입니다.") + @Size(max = 500, message = "댓글 내용은 500자를 넘을 수 없습니다.") + String content +) { +} \ No newline at end of file diff --git a/application/src/main/java/com/groom/yummy/resolver/AuthUserResolver.java b/application/src/main/java/com/groom/yummy/resolver/AuthUserResolver.java new file mode 100644 index 0000000..a8bd0e3 --- /dev/null +++ b/application/src/main/java/com/groom/yummy/resolver/AuthUserResolver.java @@ -0,0 +1,33 @@ +//package com.groom.yummy.resolver; +// +//import com.groom.yummy.domain.user.User; +//import com.groom.yummy.domain.user.UserAuthService; +//import lombok.RequiredArgsConstructor; +//import org.springframework.core.MethodParameter; +//import org.springframework.security.core.Authentication; +//import org.springframework.security.core.context.SecurityContextHolder; +//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; +// +//@RequiredArgsConstructor +//public class AuthUserResolver implements HandlerMethodArgumentResolver { +// +// private final UserAuthService userAuthService; +// +// @Override +// public boolean supportsParameter(MethodParameter parameter) { +// return User.class.isAssignableFrom(parameter.getParameterType()); +// } +// +// @Override +// public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, +// NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { +// Authentication auth = SecurityContextHolder.getContext().getAuthentication(); +// if(auth == null){ +// throw new RuntimeException(); +// } +// return userAuthService.findAuthUserByEmail(auth.getName()).orElseThrow(RuntimeException::new); +// } +//} diff --git a/application/src/main/java/com/groom/yummy/test/TestCreateController.java b/application/src/main/java/com/groom/yummy/test/TestCreateController.java new file mode 100644 index 0000000..c53ee53 --- /dev/null +++ b/application/src/main/java/com/groom/yummy/test/TestCreateController.java @@ -0,0 +1,79 @@ +package com.groom.yummy.test; + +import com.groom.yummy.dto.ResponseDto; +import com.groom.yummy.jwt.JwtProvider; +import com.groom.yummy.test.dto.SignInDto; +import com.groom.yummy.test.dto.TestUserCreateDto; +import com.groom.yummy.test.dto.TestUserResDto; +import com.groom.yummy.user.User; +import com.groom.yummy.user.UserRepository; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseCookie; +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 java.util.Optional; + +import static org.springframework.http.HttpHeaders.SET_COOKIE; + +@Tag(name = "[TestUser] TestUser API") +@RestController +@RequiredArgsConstructor +@Slf4j +@RequestMapping("/api/v1/test/users") +public class TestCreateController { + + private final JwtProvider jwtProvider; + private final UserRepository userRepository; + + @Operation(summary = "회원가입", description = "회원 가입 할 유저의 정보를 입력합니다. " + + "
회원가입이 완료되면 자동으로 유저의 기본 profile 사진이 등록됩니다." + + "
이후에 유저의 Token 을 통해 profile 사진을 수정할 수 있습니다.") + @PostMapping("/sign-up") + public ResponseEntity> signUp(@Valid @RequestBody TestUserCreateDto testUserCreateDto){ + User user = User.builder() + .email(testUserCreateDto.email()) + .nickname(testUserCreateDto.nickname()) + .role("ROLE_USER") + .groupJoinCount(0L) + .groupAttendanceCount(0L) + .isDeleted(false) + .build(); + + user = userRepository.save(user); + TestUserResDto testUserResDto = TestUserResDto.from(user); + return ResponseEntity.status(HttpStatus.CREATED).body( + new ResponseDto<>( + testUserResDto, + "테스트 유저 생성 성공")); + } + + + @Operation(summary = "로그인", description = "회원 가입 한 유저의 loginId와 password를 입력합니다.") + @PostMapping("/sign-in") + public ResponseEntity> signIn(@Valid @RequestBody SignInDto signInReqDto){ + + Optional optionalUser = userRepository.findByEmail(signInReqDto.email()); + User user = optionalUser.get(); + String accessToken = jwtProvider.createAccessToken(user.getId(), user.getEmail(),user.getNickname(),user.getRole()); + ResponseCookie cookie = ResponseCookie.from("Authorization", accessToken) + .httpOnly(true) + .secure(true) + .path("/") + .maxAge(7200000) + .sameSite("Strict") + .build(); + + return ResponseEntity.status(HttpStatus.OK) + .header(SET_COOKIE, cookie.toString()) + .body(new ResponseDto<>(1, "성공하였습니당.")); + } +} diff --git a/application/src/main/java/com/groom/yummy/test/dto/SignInDto.java b/application/src/main/java/com/groom/yummy/test/dto/SignInDto.java new file mode 100644 index 0000000..1a04d9d --- /dev/null +++ b/application/src/main/java/com/groom/yummy/test/dto/SignInDto.java @@ -0,0 +1,6 @@ +package com.groom.yummy.test.dto; + +public record SignInDto( + String email +) { +} diff --git a/application/src/main/java/com/groom/yummy/test/dto/TestUserCreateDto.java b/application/src/main/java/com/groom/yummy/test/dto/TestUserCreateDto.java new file mode 100644 index 0000000..5636750 --- /dev/null +++ b/application/src/main/java/com/groom/yummy/test/dto/TestUserCreateDto.java @@ -0,0 +1,7 @@ +package com.groom.yummy.test.dto; + +public record TestUserCreateDto( + String nickname, + String email +) { +} diff --git a/application/src/main/java/com/groom/yummy/test/dto/TestUserResDto.java b/application/src/main/java/com/groom/yummy/test/dto/TestUserResDto.java new file mode 100644 index 0000000..5ee953b --- /dev/null +++ b/application/src/main/java/com/groom/yummy/test/dto/TestUserResDto.java @@ -0,0 +1,23 @@ +package com.groom.yummy.test.dto; + +import com.groom.yummy.user.User; +import lombok.Builder; + +@Builder +public record TestUserResDto( + Long id, + String email, + String nickname, + Long joinCount, + Long participationCount +) { + public static TestUserResDto from(User user){ + return TestUserResDto.builder() + .id(user.getId()) + .email(user.getEmail()) + .nickname(user.getNickname()) + .joinCount(user.getGroupJoinCount()) + .participationCount(user.getGroupAttendanceCount()) + .build(); + } +} diff --git a/application/src/main/java/com/groom/yummy/util/CookieUtil.java b/application/src/main/java/com/groom/yummy/util/CookieUtil.java new file mode 100644 index 0000000..0f24395 --- /dev/null +++ b/application/src/main/java/com/groom/yummy/util/CookieUtil.java @@ -0,0 +1,31 @@ +package com.groom.yummy.util; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; + +public class CookieUtil { + public static Cookie createCookie(final String nameOfCookie, final String token, + final Long cookieValidTime) { + Cookie cookie = new Cookie(nameOfCookie,token); + cookie.setPath("/"); + cookie.setHttpOnly(true); + cookie.setMaxAge(Math.toIntExact(cookieValidTime)); + cookie.setSecure(true); + return cookie; + } + + public static Cookie getCookie(HttpServletRequest request, String nameOfCookie) { + Cookie[] cookies = request.getCookies(); + + if (cookies == null || cookies.length == 0) { + return null; + } + + for (Cookie cookie : cookies) { + if (cookie.getName().equals(nameOfCookie)) { + return cookie; + } + } + return null; + } +} \ No newline at end of file diff --git a/application/src/main/java/com/groom/yummy/webclient/SomeApiService.java b/application/src/main/java/com/groom/yummy/webclient/SomeApiService.java new file mode 100755 index 0000000..668e8b9 --- /dev/null +++ b/application/src/main/java/com/groom/yummy/webclient/SomeApiService.java @@ -0,0 +1,69 @@ +package com.groom.yummy.webclient; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.groom.yummy.store.Category_; +import com.groom.yummy.store.Store; +import com.groom.yummy.store.StoreService; +import com.groom.yummy.store.dto.StoreApiResponseDto; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; + +@Service +@RequiredArgsConstructor +public class SomeApiService { + private final WebClient.Builder webClientBuilder; + private final StoreService storeService; // StoreService 주입 + + public List fetchStoresFromApi(String regionCode) { + WebClient webClient = webClientBuilder.build(); + try { + String response = webClient.get() + .uri(uriBuilder -> uriBuilder + .path("/api/v1/stores") + .queryParam("regionCode", regionCode) + .build()) + .retrieve() + .bodyToMono(String.class) + .block(); + + // JSON 응답을 파싱하여 StoreApiResponseDto 리스트 생성 + List storeDtos = parseStoresFromResponse(response); + + // Store 객체로 변환 후 StoreService에 저장 요청 + storeDtos.forEach(dto -> { + Store store = Store.builder() + .storeId(dto.getStoreId()) + .name(dto.getName()) + .category(Category_.fromApiCode(dto.getCategory())) + .regionId(dto.getRegionId()) + .build(); + storeService.createStore(store); + }); + + return storeDtos; // 반환값 추가 + } catch (Exception e) { + throw new RuntimeException("WebClient 요청 중 오류 발생", e); + } + } + + + private List parseStoresFromResponse(String response) { + ObjectMapper objectMapper = new ObjectMapper(); + try { + JsonNode storesNode = objectMapper.readTree(response).path("data").path("stores"); + return StreamSupport.stream(storesNode.spliterator(), false) + .map(StoreApiResponseDto::fromJsonNode) + .collect(Collectors.toList()); + } catch (Exception e) { + throw new RuntimeException("JSON 파싱 중 오류 발생", e); + } + } +} + + diff --git a/application/src/main/resources/application.yml b/application/src/main/resources/application.yml new file mode 100644 index 0000000..df3f7a1 --- /dev/null +++ b/application/src/main/resources/application.yml @@ -0,0 +1,139 @@ +spring: + datasource: + url: jdbc:mysql://localhost:3306/yummy_db + username: yummy + password: yummy1234! + driver-class-name: com.mysql.cj.jdbc.Driver + + jpa: + hibernate: + ddl-auto: create + show-sql: true + open-in-view: false + properties: + hibernate: + format_sql: true + jdbc: + time_zone: Asia/Seoul + + security: + oauth2: + client: + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id + registration: + kakao: + client-name: kakao + client-id: ${client-id} + client-secret: ${client-secret} + redirect-uri: ${redirect-uri} + client-authentication-method: client_secret_post + authorization-grant-type: authorization_code + scope: profile_nickname, account_email + + jwt: + secret: qwdhui83jvnllw133qkjbcaw712jnfe982329H#p219qnf43yj90j32 + valid-time: 7200000 + cookie-name: Authorization + +server: + port: 8081 + url: http://localhost:8081 +--- + +spring: + config: + activate: + on-profile: prod + + datasource: + url: ${DB_URL} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + + jpa: + hibernate: + ddl-auto: create-drop + show-sql: true + open-in-view: false + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.MySQLDialect + + security: + oauth2: + client: + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id + registration: + kakao: + client-name: kakao + client-id: ${CLIENT_ID} + client-secret: ${CLIENT_SECRET} + redirect-uri: ${REDIRECT_URI} + client-authentication-method: client_secret_post + authorization-grant-type: authorization_code + scope: profile_nickname, account_email + + jwt: + secret: ${JWT_SECRET} + valid-time: 7200000 + cookie-name: Authorization + +server: + port: 8080 + url: https://52.78.217.57.nip.io +--- +spring: + config: + activate: + on-profile: test + datasource: + url: jdbc:h2:mem:testdb + driver-class-name: org.h2.Driver + username: sa + password: + h2: + console: + enabled: true + jpa: + hibernate: + ddl-auto: create + properties: + hibernate: + dialect: org.hibernate.dialect.H2Dialect + show_sql: true + format_sql: true + default_batch_fetch_size: 50 + security: + oauth2: + client: + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id + registration: + kakao: + client-name: kakao + client-id: ${client-id} + client-secret: ${client-secret} + redirect-uri: ${redirect-uri} + client-authentication-method: client_secret_post + authorization-grant-type: authorization_code + scope: profile_nickname, account_email + jwt: + secret: qwdhui83jvnllw133qkjbcaw712jnfe982329H#p219qnf43yj90j32 + valid-time: 7200000 + cookie-name: Authorization \ No newline at end of file diff --git a/application/src/test/java/com/groom/yummy/controller/GroupControllerTest.java b/application/src/test/java/com/groom/yummy/controller/GroupControllerTest.java new file mode 100755 index 0000000..60c5f96 --- /dev/null +++ b/application/src/test/java/com/groom/yummy/controller/GroupControllerTest.java @@ -0,0 +1,254 @@ +package com.groom.yummy.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.groom.yummy.group.Group; +import com.groom.yummy.group.GroupService; +import com.groom.yummy.group.MeetingStatus; +import com.groom.yummy.group.dto.request.CreateGroupRequestDto; +import com.groom.yummy.group.dto.request.JoinGroupRequestDto; +import com.groom.yummy.group.dto.response.GroupDetailResponseDto; +import com.groom.yummy.jwt.JwtProvider; +import com.groom.yummy.oauth2.auth.LoginUser; +import com.groom.yummy.user.User; +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(GroupController.class) +class GroupControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private GroupService groupService; + + @MockitoBean + private JwtProvider jwtProvider; + + private Cookie authCookie; + + @BeforeEach + void setUp() { + // Mock User 객체 생성 + User mockUser = User.builder() + .id(1L) + .email("testUser@gmail.com") + .nickname("testNickname") + .role("USER") + .build(); + + // Mock LoginUser 생성 + LoginUser mockLoginUser = new LoginUser(mockUser); + + // 인증 객체 생성 + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(mockLoginUser, null, List.of()) + ); + + // JWT 검증 + when(jwtProvider.validateToken("valid.jwt.token")).thenReturn(true); + when(jwtProvider.getUsername("valid.jwt.token")).thenReturn("testUser@gmail.com"); + + // Authorization 쿠키 생성 + authCookie = new Cookie("Authorization", "valid.jwt.token"); + } + + @Test + @Order(1) + @DisplayName("사용자는 소모임을 생성할 수 있다.") + void 소모임_생성_테스트() throws Exception { + // given + CreateGroupRequestDto requestDto = CreateGroupRequestDto.builder() + .title("Go to Goorm Store!") + .content("yummy yummy yummy yummy") + .maxParticipants(4) + .minParticipants(3) + .meetingDate(LocalDateTime.now()) + .storeId(10L) + .build(); + + // Mock groupId 반환값 + Long mockGroupId = 1L; + + // Mock 그룹 생성 동작 + when(groupService.createGroup(any(), any(), any(), any(), any(), any(), any())) + .thenReturn(mockGroupId); + + // Mock findGroupById 반환값 설정 + Group mockGroup = Group.builder() + .id(mockGroupId) + .title(requestDto.getTitle()) + .content(requestDto.getContent()) + .maxParticipants(requestDto.getMaxParticipants()) + .minParticipants(requestDto.getMinParticipants()) + .currentParticipants(1) + .meetingDate(requestDto.getMeetingDate()) + .meetingStatus(MeetingStatus.OPEN) + .storeId(requestDto.getStoreId()) + .build(); + + when(groupService.findGroupById(mockGroupId)).thenReturn(Optional.of(mockGroup)); + + String body = objectMapper.writeValueAsString(requestDto); + + // when + ResultActions actions = mockMvc.perform(post("/api/v1/groups") + .cookie(authCookie) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(body)); + + // then + actions.andExpectAll( + status().isCreated(), + jsonPath("$.msg").value("소모임 생성 성공"), + jsonPath("$.data.id").value(1L) + ); + + // 호출 횟수 검증 + verify(groupService, times(1)).createGroup(any(), any(), any(), any(), any(), any(), any()); + verify(groupService, times(1)).findGroupById(mockGroupId); + } + + + @Test + @Order(2) + @DisplayName("사용자는 소모임 목록을 조회할 수 있다.") + void 소모임_리스트_조회_테스트() throws Exception { + // given + Group group = Group.builder() + .id(1L) + .title("Go to Goorm Store!") + .content("yummy yummy yummy yummy") + .maxParticipants(4) + .minParticipants(3) + .currentParticipants(1) + .meetingDate(LocalDateTime.now()) + .meetingStatus(MeetingStatus.OPEN) + .storeId(10L) + .build(); + + when(groupService.getAllGroups(any(), any(), any(), anyInt())) + .thenReturn(List.of(group)); + + // when + ResultActions actions = mockMvc.perform(get("/api/v1/groups") + .param("category", "KOREAN") + .param("regionCode", "110110110") + .param("storeName", "구룸식당") + .param("page", "1") + .cookie(authCookie)); + + // then + actions.andExpectAll( + status().isOk(), + jsonPath("$.msg").value("소모임 목록 조회 성공"), + jsonPath("$.data[0].id").value(1L), + jsonPath("$.data[0].title").value("Go to Goorm Store!") + ); + + verify(groupService, times(1)).getAllGroups(any(), any(), any(), anyInt()); + } + + @Test + @Order(3) + @DisplayName("사용자는 소모임 상세 정보를 조회할 수 있다.") + void 소모임_상세정보_조회_테스트() throws Exception { + // given + GroupDetailResponseDto responseDto = GroupDetailResponseDto.builder() + .id(1L) + .title("Go to Goorm Store!") + .content("yummy yummy yummy yummy") + .maxParticipants(4) + .minParticipants(3) + .currentParticipants(1) + .meetingDate(LocalDateTime.now()) + .meetingStatus("OPEN") + .storeId(10L) + .createdAt(LocalDateTime.now()) + .build(); + + when(groupService.findGroupById(1L)).thenReturn(Optional.of(Group.builder() + .id(1L) + .title("Go to Goorm Store!") + .content("yummy yummy yummy yummy") + .maxParticipants(4) + .minParticipants(3) + .currentParticipants(1) + .meetingDate(LocalDateTime.now()) + .meetingStatus(MeetingStatus.OPEN) + .storeId(10L) + .build())); + + // when + ResultActions actions = mockMvc.perform(get("/api/v1/groups/{groupId}", 1L) + .cookie(authCookie)); + + // then + actions.andExpectAll( + status().isOk(), + jsonPath("$.msg").value("소모임 상세 정보 조회 성공"), + jsonPath("$.data.id").value(1L), + jsonPath("$.data.title").value("Go to Goorm Store!") + ); + + verify(groupService, times(1)).findGroupById(1L); + } + + @Test + @Order(4) + @DisplayName("사용자는 소모임에 참여할 수 있다.") + void 소모임_가입_테스트() throws Exception { + // given + Long groupId = 1L; + Long storeId = 10L; + + JoinGroupRequestDto joinRequestDto = JoinGroupRequestDto.builder() + .storeId(storeId) + .build(); + + doNothing().when(groupService).joinGroup(eq(groupId), any(), eq(storeId)); + + String body = objectMapper.writeValueAsString(joinRequestDto); + + // when + ResultActions actions = mockMvc.perform(post("/api/v1/groups/{groupId}/join", groupId) + .cookie(authCookie) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(body)); + + // then + actions.andExpectAll( + status().isOk(), + jsonPath("$.msg").value("소모임 참여 성공") + ); + + verify(groupService, times(1)).joinGroup(eq(groupId), any(), eq(storeId)); + } +} diff --git a/application/src/test/java/com/groom/yummy/controller/ReplyControllerTest.java b/application/src/test/java/com/groom/yummy/controller/ReplyControllerTest.java new file mode 100644 index 0000000..7ed7954 --- /dev/null +++ b/application/src/test/java/com/groom/yummy/controller/ReplyControllerTest.java @@ -0,0 +1,193 @@ +package com.groom.yummy.controller; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.groom.yummy.reply.Reply; +import com.groom.yummy.reply.ReplyService; +import com.groom.yummy.jwt.JwtProvider; +import com.groom.yummy.reply.dto.ReplyRequestDto; +import com.groom.yummy.reply.dto.ReplyUpdateRequestDto; + +import static org.mockito.Mockito.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; + +import jakarta.servlet.http.Cookie; + +@WebMvcTest(ReplyController.class) +class ReplyControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private ReplyService replyService; + + @MockitoBean + private JwtProvider jwtProvider; + + private Cookie authCookie; + + @BeforeEach + void setUp() { + // Mock 사용자 설정 + UserDetails principal = org.springframework.security.core.userdetails.User + .withUsername("testUser@gmail.com") + .password("password") // 비밀번호는 테스트용이므로 중요하지 않음 + .roles("USER") // 역할 설정 + .build(); + + // 인증 객체 생성 + Authentication authentication = new UsernamePasswordAuthenticationToken( + principal, principal.getPassword(), principal.getAuthorities() + ); + + // SecurityContext에 인증 정보 설정 + SecurityContextHolder.getContext().setAuthentication(authentication); + + when(jwtProvider.validateToken("valid.jwt.token")).thenReturn(true); + when(jwtProvider.getUsername("valid.jwt.token")).thenReturn("testUser@gmail.com"); + + // 쿠키 객체 생성 + authCookie = new Cookie("Authorization", "valid.jwt.token"); + } + + + @Test + @DisplayName("사용자는 댓글을 등록할 수 있다.") + void registerReplyTest() throws Exception { + // given + Long groupId = 1L; + String content = "댓글 내용"; + ReplyRequestDto requestDto = new ReplyRequestDto(content, null, 1L, groupId); + Reply savedReply = Reply.builder().id(1L).content(content).groupId(groupId).build(); + + // Mocking 서비스 호출 + when(replyService.createReply(any(Reply.class))).thenReturn(savedReply); + + String body = objectMapper.writeValueAsString(requestDto); + + // when + ResultActions actions = mockMvc.perform(post("/api/v1/reply") + .cookie(authCookie) // Authorization 쿠키 설정 + .with(csrf()) // CSRF 토큰 설정 + .contentType(MediaType.APPLICATION_JSON) + .content(body)); + + // then + actions.andExpectAll( + status().isOk(), + jsonPath("$.data.id").value(savedReply.getId()), + jsonPath("$.data.content").value(savedReply.getContent()), + jsonPath("$.msg").value("댓글을 성공적으로 작성하였습니다.") + ); + + // verify 호출 횟수 검증 + verify(replyService, times(1)).createReply(any(Reply.class)); + } + + @Test + @DisplayName("사용자는 특정 소모임의 댓글을 조회할 수 있다.") + void getAllRepliesTest() throws Exception { + // given + Long groupId = 1L; + Reply reply1 = Reply.builder().id(1L).content("댓글1").groupId(groupId).build(); + Reply reply2 = Reply.builder().id(2L).content("댓글2").groupId(groupId).build(); + Page replyPage = new PageImpl<>(List.of(reply1, reply2)); + + when(replyService.getAllReplies(eq(groupId), any())).thenReturn(replyPage); + + // when + ResultActions actions = mockMvc.perform(get("/api/v1/reply/all") + .param("groupId", groupId.toString()) + .param("page", "0") + .param("size", "10") + .cookie(authCookie) + .contentType(MediaType.APPLICATION_JSON)); + + // then + actions.andExpectAll( + status().isOk(), + jsonPath("$.data.content").isArray(), + jsonPath("$.msg").value("댓글 조회 성공") + ); + + verify(replyService, times(1)).getAllReplies(eq(groupId), any()); + } + + @Test + @DisplayName("사용자는 댓글을 수정할 수 있다.") + void updateReplyTest() throws Exception { + // given + Long replyId = 1L; + String updatedContent = "수정된 댓글 내용"; + ReplyUpdateRequestDto updateRequestDto = new ReplyUpdateRequestDto(updatedContent); + Reply updatedReply = Reply.builder().id(replyId).content(updatedContent).build(); + + when(replyService.updateReply(eq(replyId), eq(updatedContent))).thenReturn(updatedReply); + + String body = objectMapper.writeValueAsString(updateRequestDto); + + // when + ResultActions actions = mockMvc.perform(patch("/api/v1/reply/" + replyId) + .cookie(authCookie) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(body)); + + // then + actions.andExpectAll( + status().isOk(), + jsonPath("$.data.id").value(replyId), + jsonPath("$.data.content").value(updatedContent), + jsonPath("$.msg").value("댓글이 성공적으로 수정되었습니다.") + ); + + verify(replyService, times(1)).updateReply(eq(replyId), eq(updatedContent)); + } + + @Test + @DisplayName("사용자는 댓글을 삭제할 수 있다.") + void deleteReplyTest() throws Exception { + // given + Long replyId = 1L; + + doNothing().when(replyService).deleteReply(replyId); + + // when + ResultActions actions = mockMvc.perform(delete("/api/v1/reply/" + replyId) + .cookie(authCookie) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON)); + + // then + actions.andExpectAll( + status().isOk(), + jsonPath("$.msg").value("댓글이 성공적으로 삭제되었습니다.") + ); + + verify(replyService, times(1)).deleteReply(replyId); + } +} diff --git a/application/src/test/java/com/groom/yummy/controller/UserControllerTest.java b/application/src/test/java/com/groom/yummy/controller/UserControllerTest.java new file mode 100644 index 0000000..86e3b67 --- /dev/null +++ b/application/src/test/java/com/groom/yummy/controller/UserControllerTest.java @@ -0,0 +1,272 @@ +package com.groom.yummy.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.groom.yummy.config.SecurityConfig; +import com.groom.yummy.exception.CustomException; +import com.groom.yummy.exception.JwtErrorCode; + +import com.groom.yummy.user.dto.request.UpdateNicknameReqDto; +import com.groom.yummy.user.dto.response.UserInfoResDto; +import com.groom.yummy.user.facade.UserFacade; +import com.groom.yummy.jwt.JwtProvider; +import com.groom.yummy.oauth2.handler.CustomSuccessHandler; +import com.groom.yummy.oauth2.service.CustomOAuth2UserService; +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + + +@ActiveProfiles("test") +@Import({SecurityConfig.class}) +@WebMvcTest(controllers = {UserController.class}) +class UserControllerTest { + + @Autowired + protected MockMvc mockMvc; + @Autowired + protected ObjectMapper objectMapper; + @MockitoBean + protected JwtProvider jwtProvider; + @MockitoBean + protected UserFacade userFacade; + @MockitoBean + private CustomOAuth2UserService customOAuth2UserService; + @MockitoBean + private CustomSuccessHandler customSuccessHandler; + + @BeforeEach + void setUp(){ + String mockJwtToken = "mockJwtToken"; + // JwtProvider Mock 설정 + when(jwtProvider.validateToken(mockJwtToken)).thenReturn(false); + when(jwtProvider.getUserId(mockJwtToken)).thenReturn(1L); + when(jwtProvider.getUsername(mockJwtToken)).thenReturn("email@gmail.com"); + when(jwtProvider.getName(mockJwtToken)).thenReturn("강형준"); + when(jwtProvider.getRole(mockJwtToken)).thenReturn("ROLE_USER"); + } + + @Test + @DisplayName("토큰 기반 정보로 유저 조회 테스트") + void getUserInfoByTokenTest() throws Exception { + // given + Long userId = 1L; + String jwtToken = "mockJwtToken"; + String email = "email@gmail.com"; + String nickname = "강형준"; + UserInfoResDto mockResponse = UserInfoResDto.builder().id(userId).email(email).nickname(nickname).build(); + when(userFacade.getUserInfo(userId)).thenReturn(mockResponse); + + // When & Then + ResultActions actions = mockMvc.perform(get("/api/v1/users") + .cookie(new Cookie("Authorization", jwtToken)) // 쿠키 추가 + .contentType(MediaType.APPLICATION_JSON)) + .andExpectAll( + status().isOk(), + jsonPath("$.data.id").value(userId), + jsonPath("$.data.email").value(email), + jsonPath("$.data.nickname").value(nickname), + jsonPath("$.msg").value("자신의 정보 조회 성공") + ); + + verify(userFacade, times(1)) + .getUserInfo(any(Long.class)); + verify(jwtProvider, times(1)) + .validateToken(any(String.class)); + } + + @Test + @DisplayName("토큰 검증 실패 - WRONG_TYPE_TOKEN 예외 테스트") + void getUserInfoWithWrongTypeTokenTest() throws Exception { + // given + String jwtToken = "mockJwtToken"; + JwtErrorCode errorCode = JwtErrorCode.WRONG_TYPE_TOKEN; + doThrow(new CustomException(errorCode)) + .when(jwtProvider).validateToken(any(String.class)); + + // When & Then + mockMvc.perform(get("/api/v1/users") + .cookie(new Cookie("Authorization", jwtToken)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.msg").value(errorCode.getMessage())) // 에러 메시지 검증 + .andExpect(jsonPath("$.data").value(-1L)); + + verify(jwtProvider, times(1)).validateToken(any(String.class)); + verify(userFacade, never()).getUserInfo(any(Long.class)); // 유저 조회는 호출되지 않아야 함 + } + + @Test + @DisplayName("토큰 검증 실패 - EXPIRED_TOKEN 예외 테스트") + void getUserInfoWithExpiredTokenTest() throws Exception { + // given + String jwtToken = "expiredJwtToken"; + JwtErrorCode errorCode = JwtErrorCode.EXPIRED_TOKEN; + doThrow(new CustomException(errorCode)) + .when(jwtProvider).validateToken(any(String.class)); + + // When & Then + mockMvc.perform(get("/api/v1/users") + .cookie(new Cookie("Authorization", jwtToken)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.msg").value(errorCode.getMessage())) + .andExpect(jsonPath("$.data").value(-1L)); + + verify(jwtProvider, times(1)).validateToken(any(String.class)); + verify(userFacade, never()).getUserInfo(any(Long.class)); // 유저 조회는 호출되지 않아야 함 + } + + @Test + @DisplayName("토큰 검증 실패 - UNKNOWN_TOKEN_ERROR 예외 테스트") + void getUserInfoWithUnknownTokenErrorTest() throws Exception { + // given + String jwtToken = "unknownJwtToken"; + JwtErrorCode errorCode = JwtErrorCode.UNKNOWN_TOKEN_ERROR; + doThrow(new CustomException(errorCode)) + .when(jwtProvider).validateToken(any(String.class)); + + // When & Then + mockMvc.perform(get("/api/v1/users") + .cookie(new Cookie("Authorization", jwtToken)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.msg").value(errorCode.getMessage())) + .andExpect(jsonPath("$.data").value(-1L)); + + verify(jwtProvider, times(1)).validateToken(any(String.class)); + verify(userFacade, never()).getUserInfo(any(Long.class)); // 유저 조회는 호출되지 않아야 함 + } + + @Test + @DisplayName("userId로 유저를 조회 테스트") + void getUserInfoTest() throws Exception{ + // given + Long userId = 1L; + String jwtToken = "mockJwtToken"; + String email = "email@gmail.com"; + String nickname = "홍길동"; + UserInfoResDto mockResponse = UserInfoResDto.builder().id(userId).email(email).nickname(nickname).build(); + when(userFacade.getUserInfo(userId)).thenReturn(mockResponse); + + // When & Then + ResultActions actions = mockMvc.perform(get("/api/v1/users/" + userId) + .cookie(new Cookie("Authorization", jwtToken)) // 쿠키 추가 + .contentType(MediaType.APPLICATION_JSON)) + .andExpectAll( + status().isOk(), + jsonPath("$.data.id").value(userId), + jsonPath("$.data.email").value(email), + jsonPath("$.data.nickname").value(nickname), + jsonPath("$.msg").value("유저 정보 조회 성공") + ); + + verify(userFacade, times(1)) + .getUserInfo(any(Long.class)); + verify(jwtProvider, times(1)) + .validateToken(any(String.class)); + } + + @Test + @DisplayName("유저 nickname 변경 테스트.") + void updateUserNicknameTest() throws Exception{ + // given + Long userId = 1L; + String jwtToken = "mockJwtToken"; + String email = "email@gmail.com"; + String updateNick = "이후강형준"; + + UpdateNicknameReqDto updateNicknameReqDto = UpdateNicknameReqDto.builder().nickname(updateNick).build(); + String body = objectMapper.writeValueAsString(updateNicknameReqDto); + + UserInfoResDto mockResponse = UserInfoResDto.builder().id(userId).email(email).nickname(updateNick).build(); + when(userFacade.updateUserNickname(userId, updateNicknameReqDto)).thenReturn(mockResponse); + + // When & Then + ResultActions actions = mockMvc.perform(patch("/api/v1/users/profile") + .cookie(new Cookie("Authorization", jwtToken)) // 쿠키 추가 + .content(body) + .contentType(MediaType.APPLICATION_JSON)) + .andExpectAll( + status().isOk(), + jsonPath("$.data.id").value(userId), + jsonPath("$.data.email").value(email), + jsonPath("$.data.nickname").value(updateNick), + jsonPath("$.msg").value("유저 닉네임 변경 성공") + ); + + verify(userFacade, times(1)) + .updateUserNickname(any(Long.class), any(UpdateNicknameReqDto.class)); + verify(jwtProvider, times(1)) + .validateToken(any(String.class)); + } + + @Test + @DisplayName("유저 닉네임은 공백일 수 없습니다.") + void updateUserNicknameTest_NonBlank() throws Exception{ + // given + Long userId = 1L; + String jwtToken = "mockJwtToken"; + String email = "email@gmail.com"; + String updateNick = ""; + + UpdateNicknameReqDto updateNicknameReqDto = UpdateNicknameReqDto.builder().nickname(updateNick).build(); + String body = objectMapper.writeValueAsString(updateNicknameReqDto); + + // When & Then + ResultActions actions = mockMvc.perform(patch("/api/v1/users/profile") + .cookie(new Cookie("Authorization", jwtToken)) // 쿠키 추가 + .content(body) + .contentType(MediaType.APPLICATION_JSON)) + .andExpectAll( + status().isBadRequest(), + jsonPath("$.data").value(-1L), + jsonPath("$.msg").value("must not be blank") + ); + + verify(userFacade, never()) + .updateUserNickname(any(Long.class), any(UpdateNicknameReqDto.class)); // 메서드가 호출되지 않아야 함 + verify(jwtProvider, times(1)) + .validateToken(any(String.class)); + } + + @Test + @DisplayName("유저 삭제(논리) 테스트") + void deleteUserByTokenTest() throws Exception{ + // given + Long userId = 1L; + String jwtToken = "mockJwtToken"; + + when(userFacade.deleteUser(userId)).thenReturn(userId); + + // When & Then + ResultActions actions = mockMvc.perform(delete("/api/v1/users") + .cookie(new Cookie("Authorization", jwtToken)) // 쿠키 추가 + .contentType(MediaType.APPLICATION_JSON)) + .andExpectAll( + status().isOk(), + jsonPath("$.data").value(userId), + jsonPath("$.msg").value("회원정보 삭제 성공") + ); + + verify(userFacade, times(1)) + .deleteUser(any(Long.class)); + verify(jwtProvider, times(1)) + .validateToken(any(String.class)); + } +} \ No newline at end of file diff --git a/application/src/test/java/com/groom/yummy/oauth2/handler/CustomSuccessHandlerTest.java b/application/src/test/java/com/groom/yummy/oauth2/handler/CustomSuccessHandlerTest.java new file mode 100644 index 0000000..cc407fa --- /dev/null +++ b/application/src/test/java/com/groom/yummy/oauth2/handler/CustomSuccessHandlerTest.java @@ -0,0 +1,75 @@ +package com.groom.yummy.oauth2.handler; + +import com.groom.yummy.jwt.JwtProvider; +import com.groom.yummy.oauth2.auth.LoginUser; +import com.groom.yummy.user.User; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class CustomSuccessHandlerTest { + + @InjectMocks + private CustomSuccessHandler customSuccessHandler; + @Mock + private JwtProvider jwtProvider; + private MockHttpServletRequest request; + private MockHttpServletResponse response; + + @BeforeEach + void setUp(){ + request = new MockHttpServletRequest(); + response = new MockHttpServletResponse(); + jwtProvider.COOKIE_NAME = "Authorization"; + jwtProvider.VALID_TIME = 1000L; + } + + @Test + @DisplayName("OAuth2 성공 핸들러 테스트") + void onAuthenticationSuccessTest() throws ServletException, IOException { + // given + Long userId = 1L; + String email = "email@gmail.com"; + String nickname = "강형준"; + String role = "ROLE_USER"; + String expectedToken = "mockAccessToken"; + String cookieName = "Authorization"; + Long validTime = 1000L; + + User user = User.builder().id(userId).email(email).nickname(nickname).role(role).build(); + LoginUser loginUser = new LoginUser(user); + + Authentication authentication = new UsernamePasswordAuthenticationToken(loginUser, null); + + when(jwtProvider.createAccessToken(userId, email, nickname, role)).thenReturn(expectedToken); + + // when + customSuccessHandler.onAuthenticationSuccess(request, response, authentication); + + // then + Cookie cookie = response.getCookie(cookieName); + assertNotNull(cookie); + assertEquals(expectedToken, cookie.getValue()); + assertEquals(validTime, cookie.getMaxAge()); + + verify(jwtProvider, times(1)) + .createAccessToken(userId, email, nickname,role); + } +} \ No newline at end of file diff --git a/application/src/test/java/com/groom/yummy/oauth2/service/CustomOAuth2UserServiceTest.java b/application/src/test/java/com/groom/yummy/oauth2/service/CustomOAuth2UserServiceTest.java new file mode 100644 index 0000000..dcd92a8 --- /dev/null +++ b/application/src/test/java/com/groom/yummy/oauth2/service/CustomOAuth2UserServiceTest.java @@ -0,0 +1,140 @@ +package com.groom.yummy.oauth2.service; + +import com.groom.yummy.user.facade.UserFacade; +import com.groom.yummy.oauth2.auth.LoginUser; +import com.groom.yummy.oauth2.dto.OAuth2Response; +import com.groom.yummy.oauth2.strategy.OAuth2ResFactory; +import com.groom.yummy.user.User; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import java.time.Instant; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +@ExtendWith(MockitoExtension.class) +class CustomOAuth2UserServiceTest { + @InjectMocks + private CustomOAuth2UserService customOAuth2UserService; + + @Mock + private OAuth2ResFactory oAuth2ResFactory; + + @Mock + private UserFacade userFacade; + + @Mock + private DefaultOAuth2UserService defaultOAuth2UserService; + + Map attributes; + + @BeforeEach + void setUp(){ + String registrationId = "kakao"; + String email = "email@gmail.com"; + String nickname = "강형준"; + + attributes = Map.of( + "id", registrationId, + "kakao_account", Map.of("email", email), + "properties", Map.of("nickname", nickname) + ); + } + + @Test + void kakaoOAuth2loadUserTest() throws Exception { + // given + String registrationId = "kakao"; + String email = "email@gmail.com"; + String nickname = "강형준"; + String oauth2AccessToken = "testAccessToken"; + + // 가짜 웹 서버를 통해 실제 카카오 서버와의 통신을 mocking 합니다. + MockWebServer mockWebServer = new MockWebServer(); + mockWebServer.start(); + + // 받은 엑세스 토큰을 통해, 클라이언트가 UserInfo Endpoint 요청 보내 사용자 정보를 받는 동작 Mocking + String userInfoResponse = "{ \"id\": \"" + registrationId + + "\", \"kakao_account\": { \"email\": \"" + email + "\" }," + + " \"properties\": { \"nickname\": \"" + nickname + "\" } }"; + mockWebServer.enqueue(new MockResponse() + .setResponseCode(200) + .setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .setBody(userInfoResponse) + ); + + // Mock OAuth2AccessToken: 엑세스 토큰 Mocking + OAuth2AccessToken accessToken = new OAuth2AccessToken( + OAuth2AccessToken.TokenType.BEARER, + oauth2AccessToken, + Instant.now(), + Instant.now().plusSeconds(3600) + ); + + // OAuth2 인증 및 사용자 정보 요청 과정에서 필요한 클라이언트와 요청 정보를 Mocking + ClientRegistration clientRegistration = ClientRegistration.withRegistrationId("kakao") + .clientId("test-client-id") + .clientSecret("test-client-secret") + .authorizationUri("https://kauth.kakao.com/oauth/authorize") + .tokenUri("https://kauth.kakao.com/oauth/token") + .userInfoUri(mockWebServer.url("/mock/url").toString()) + .userNameAttributeName("id") + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") + .build(); + OAuth2UserRequest userRequest = new OAuth2UserRequest(clientRegistration, accessToken); + + // 인증 이후 받은 사용자 정보 Mocking + OAuth2User oAuth2User = Mockito.mock(OAuth2User.class); + Mockito.when(oAuth2User.getAttributes()).thenReturn(Map.of( + "id", registrationId, + "kakao_account", Map.of("email", email), + "properties", Map.of("nickname", nickname) + )); + + // 받은 사용자 정보를 변환하는 OAuth2Response 클래스 Mocking + OAuth2Response oAuth2Response = Mockito.mock(OAuth2Response.class); + Mockito.when(oAuth2Response.getEmail()).thenReturn(email); + Mockito.when(oAuth2Response.getName()).thenReturn(nickname); + Mockito.when(oAuth2ResFactory.createOAuth2Response(registrationId, oAuth2User.getAttributes())) + .thenReturn(oAuth2Response); + + // userFacade 클래스 목킹 + User mockUser = User.builder().email(email).nickname(nickname).build(); + Mockito.when(userFacade.findAuthUserByEmail(email)).thenReturn(Optional.empty()); + Mockito.when(userFacade.findOrCreateUser(Optional.empty(), nickname, email)).thenReturn(mockUser); + + // when + OAuth2User result = customOAuth2UserService.loadUser(userRequest); + + // then + assertNotNull(result); + assertTrue(result instanceof LoginUser); // 리턴 값이 LoginUser 객체인지 확ㅇㄴ + + // OAuth2User 구현체인 LoginUser 로 캐스팅 + LoginUser loginUser = (LoginUser) result; + + assertEquals(email, loginUser.getUser().getEmail()); + assertEquals(nickname, loginUser.getUser().getNickname()); + + // MockWebServer 종료 + mockWebServer.shutdown(); + } +} \ No newline at end of file diff --git a/application/src/test/java/com/groom/yummy/oauth2/service/SomeApiServiceTest.java b/application/src/test/java/com/groom/yummy/oauth2/service/SomeApiServiceTest.java new file mode 100755 index 0000000..1160e88 --- /dev/null +++ b/application/src/test/java/com/groom/yummy/oauth2/service/SomeApiServiceTest.java @@ -0,0 +1,127 @@ +package com.groom.yummy.oauth2.service; + +import com.groom.yummy.store.Store; +import com.groom.yummy.store.StoreService; +import com.groom.yummy.webclient.SomeApiService; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.*; + +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.MOCK +) +@ActiveProfiles("test") +@Import(SomeApiServiceTest.TestConfig.class) +class SomeApiServiceTest { + + @Autowired + private SomeApiService someApiService; + + @MockitoBean + private StoreService storeService; + + private static MockWebServer mockWebServer; + + + @BeforeAll + static void setUpServer() throws Exception { + mockWebServer = new MockWebServer(); + mockWebServer.start(); + } + + @AfterAll + static void tearDownServer() throws Exception { + mockWebServer.shutdown(); + } + + @Test + @DisplayName("특정 지역 요청 시 반환된 식당 정보 저장 테스트") + void 특정지역_식당정보_저장테스트() throws Exception { + // GIVEN + String mockResponseBody = """ + { + "data": { + "stores": [ + { + "storeId": 1, + "name": "맛집가게", + "regionId": 2, + "category": "001" + }, + { + "storeId": 2, + "name": "맛집가게2", + "regionId": 2, + "category": "002" + } + ] + }, + "message": "가게 목록이 성공적으로 조회되었습니다." + } + """; + + mockWebServer.enqueue(new MockResponse() + .setBody(mockResponseBody) + .addHeader("Content-Type", "application/json")); + + // WHEN + String regionCode = "1111000000"; + someApiService.fetchStoresFromApi(regionCode); + + // THEN + ArgumentCaptor storeCaptor = ArgumentCaptor.forClass(Store.class); + verify(storeService, times(2)).createStore(storeCaptor.capture()); + + List capturedStores = storeCaptor.getAllValues(); + + // 첫 번째 Store 검증 + Store store1 = capturedStores.get(0); + assertEquals("맛집가게", store1.getName()); + assertEquals("001", store1.getCategory().getApiCode()); + assertEquals(2L, store1.getRegionId()); + + // 두 번째 Store 검증 + Store store2 = capturedStores.get(1); + assertEquals("맛집가게2", store2.getName()); + assertEquals("002", store2.getCategory().getApiCode()); + assertEquals(2L, store2.getRegionId()); + + // Mock 서버 요청 검증 + RecordedRequest recordedRequest = mockWebServer.takeRequest(); + assertEquals("/api/v1/stores?regionCode=1111000000", recordedRequest.getPath()); + assertEquals("GET", recordedRequest.getMethod()); + } + + @Configuration + static class TestConfig { + @Bean + public SomeApiService someApiService(StoreService storeService) { + String baseUrl = mockWebServer.url("/").toString(); + return new SomeApiService(WebClient.builder().baseUrl(baseUrl), storeService); + } + + @Bean + public StoreService storeService() { + return mock(StoreService.class); + } + } + +} diff --git a/application/src/test/java/module/ControllerTestSupport.java b/application/src/test/java/module/ControllerTestSupport.java new file mode 100644 index 0000000..4289ade --- /dev/null +++ b/application/src/test/java/module/ControllerTestSupport.java @@ -0,0 +1,23 @@ +package module; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.groom.yummy.controller.UserController; +import com.groom.yummy.jwt.JwtProvider; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.test.web.servlet.MockMvc; + +//@WebMvcTest(controllers = {UserController.class}) +//@ExtendWith(MockitoExtension.class) // Mockito 확장 사용 +//public class ControllerTestSupport { +// +// @Autowired +// protected MockMvc mockMvc; +// @Autowired +// protected ObjectMapper objectMapper; +// @Mock +// protected JwtProvider jwtProvider; +//} diff --git a/build.gradle b/build.gradle index 6a9117c..deff39f 100644 --- a/build.gradle +++ b/build.gradle @@ -1,25 +1,84 @@ plugins { - id 'java' - id 'org.springframework.boot' version '3.4.1' - id 'io.spring.dependency-management' version '1.1.7' + id 'java' + id 'org.springframework.boot' version '3.4.1' apply false + id 'io.spring.dependency-management' version '1.1.7' apply false + id 'jacoco' } -allprojects { - repositories { - mavenCentral() - } +tasks.named('test') { + useJUnitPlatform() + finalizedBy 'jacocoTestReport' } -dependencyManagement { - imports { - mavenBom "org.springframework.boot:spring-boot-dependencies:3.4.1" - } +allprojects { + group = 'com.groom.yummy' + version = '1.0.0' // 공통 버전 설정 + repositories { + mavenCentral() + } + java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } + } } -bootJar { - enabled = false +subprojects { + apply plugin: 'java' + apply plugin: 'jacoco' + + tasks.withType(Test).configureEach { + useJUnitPlatform() + finalizedBy tasks.named("${project.name}JacocoTestReport") + } + + tasks.register("${project.name}JacocoTestReport", JacocoReport) { + dependsOn tasks.named("test") + reports { + xml.required.set(true) + xml.outputLocation.set(layout.buildDirectory.file("reports/jacoco/${project.name}-jacoco.xml")) + } + classDirectories.setFrom( + fileTree(dir: layout.buildDirectory.dir("classes/java/main")).matching { + exclude( + "**/dto/**", // DTO 제외 + "**/config/**", // Config 제외 + "**/exception/**", // Exception 제외 + "**/*Application*", // Main Application 제외 + "**/event/**", // Event 제외 + "**/response/**", // Response 관련 클래스 제외 + "com/groom/yummy/domain/group/**", // group 도메인 제외 + "com/groom/yummy/domain/reply/**" // reply 도메인 제외 + ) + } + ) + } } -jar { - enabled = false +task jacocoRootReport(type: JacocoReport) { + dependsOn subprojects.test + + reports { + xml.required = true + html.required = true + html.outputLocation = file("${buildDir}/reports/jacoco/html") + } + + additionalSourceDirs.from files(subprojects.sourceSets.main.allSource.srcDirs) + sourceDirectories.from files(subprojects.sourceSets.main.allSource.srcDirs) + classDirectories.setFrom( + files( + subprojects.collect { subproject -> + fileTree(dir: "${subproject.buildDir}/classes/java/main").matching { + exclude( + "${subproject.name}/com/groom/yummy/domain/group/**", // group 도메인 제외 + "${subproject.name}/com/groom/yummy/domain/reply/**" // reply 도메인 제외 + ) + } + } + ) + ) + executionData.from files(subprojects.jacocoTestReport.executionData) } + + diff --git a/common/build.gradle b/common/build.gradle index df7ccaa..85ca075 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -1,5 +1,7 @@ plugins { id 'java' + id 'org.springframework.boot' version '3.4.1' + id 'io.spring.dependency-management' version '1.1.7' } java { @@ -8,5 +10,20 @@ java { } dependencies { + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' -} \ No newline at end of file + compileOnly 'org.springframework.boot:spring-boot-starter-web' + implementation 'jakarta.validation:jakarta.validation-api:3.0.2' + +} + +// 'bootJar' 태스크 비활성화 +bootJar { + enabled = false +} + +// JAR 태스크 활성화 +//jar { +// enabled = false +//} \ No newline at end of file diff --git a/common/src/main/java/com/groom/yummy/dto/ResponseDto.java b/common/src/main/java/com/groom/yummy/dto/ResponseDto.java new file mode 100644 index 0000000..15a7fdd --- /dev/null +++ b/common/src/main/java/com/groom/yummy/dto/ResponseDto.java @@ -0,0 +1,15 @@ +package com.groom.yummy.dto; + + +import lombok.Builder; +import lombok.Getter; + +public record ResponseDto(T data, String msg) { + @Builder + public ResponseDto { + } + + public static ResponseDto of(T data, String msg) { + return ResponseDto.builder().data(data).msg(msg).build(); + } +} diff --git a/common/src/main/java/com/groom/yummy/exception/CustomException.java b/common/src/main/java/com/groom/yummy/exception/CustomException.java new file mode 100644 index 0000000..bfad281 --- /dev/null +++ b/common/src/main/java/com/groom/yummy/exception/CustomException.java @@ -0,0 +1,12 @@ +package com.groom.yummy.exception; + +import lombok.Getter; + +@Getter +public class CustomException extends RuntimeException{ + private ErrorCode errorCode; + public CustomException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } +} diff --git a/common/src/main/java/com/groom/yummy/exception/ErrorCode.java b/common/src/main/java/com/groom/yummy/exception/ErrorCode.java new file mode 100644 index 0000000..0164c6b --- /dev/null +++ b/common/src/main/java/com/groom/yummy/exception/ErrorCode.java @@ -0,0 +1,8 @@ +package com.groom.yummy.exception; + +import org.springframework.http.HttpStatusCode; + +public interface ErrorCode { + HttpStatusCode getCode(); + String getMessage(); +} diff --git a/domain/build.gradle b/domain/build.gradle index 2a8af13..cc71f7a 100644 --- a/domain/build.gradle +++ b/domain/build.gradle @@ -10,19 +10,42 @@ version = 'unspecified' dependencies { + compileOnly project(':common') + testImplementation project(':common') + + // Swagger + compileOnly 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2' + + // JPA implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + + // Spring boot implementation 'org.springframework.boot:spring-boot-starter-web' + // lombok implementation 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' + // JUnit testImplementation platform('org.junit:junit-bom:5.10.0') testImplementation 'org.junit.jupiter:junit-jupiter' - implementation project(':storage') - implementation project(':common') + // Mockito + testImplementation 'org.mockito:mockito-junit-jupiter:5.5.0' + + // AssertJ + testImplementation 'org.assertj:assertj-core:3.24.2' + } test { useJUnitPlatform() -} \ No newline at end of file +} + +bootJar { + enabled = false +} + +//jar { +// enabled = false +//} diff --git a/domain/src/main/java/com/groom/yummy/exception/GroupErrorCode.java b/domain/src/main/java/com/groom/yummy/exception/GroupErrorCode.java new file mode 100644 index 0000000..c2c2027 --- /dev/null +++ b/domain/src/main/java/com/groom/yummy/exception/GroupErrorCode.java @@ -0,0 +1,19 @@ +package com.groom.yummy.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public enum GroupErrorCode implements ErrorCode{ + GROUP_NOT_FOUND(HttpStatus.NOT_FOUND, "그룹이 존재하지 않습니다."), + GROUP_PARTICIPATION_FULL(HttpStatus.NOT_FOUND, "참가 인원이 가득 찼습니다."), + GROUP_CREATE_FAILED(HttpStatus.NOT_FOUND, "그룹 생성이 실패했습니다.") + ; + private final HttpStatus code; + private final String message; + + GroupErrorCode(HttpStatus code, String message) { + this.code = code; + this.message = message; + } +} diff --git a/domain/src/main/java/com/groom/yummy/exception/ReplyErrorCode.java b/domain/src/main/java/com/groom/yummy/exception/ReplyErrorCode.java new file mode 100644 index 0000000..bd9bd62 --- /dev/null +++ b/domain/src/main/java/com/groom/yummy/exception/ReplyErrorCode.java @@ -0,0 +1,17 @@ +package com.groom.yummy.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public enum ReplyErrorCode implements ErrorCode{ + REPLY_NOT_FOUND(HttpStatus.NOT_FOUND, "댓글이 존재하지 않습니다."), + ; + private final HttpStatus code; + private final String message; + + ReplyErrorCode(HttpStatus code, String message) { + this.code = code; + this.message = message; + } +} diff --git a/domain/src/main/java/com/groom/yummy/exception/StoreErrorCode.java b/domain/src/main/java/com/groom/yummy/exception/StoreErrorCode.java new file mode 100644 index 0000000..dd9403f --- /dev/null +++ b/domain/src/main/java/com/groom/yummy/exception/StoreErrorCode.java @@ -0,0 +1,17 @@ +package com.groom.yummy.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public enum StoreErrorCode implements ErrorCode{ + STORE_NOT_FOUND(HttpStatus.NOT_FOUND, "가게가 존재하지 않습니다."), + ; + private final HttpStatus code; + private final String message; + + StoreErrorCode(HttpStatus code, String message) { + this.code = code; + this.message = message; + } +} diff --git a/domain/src/main/java/com/groom/yummy/exception/UserErrorCode.java b/domain/src/main/java/com/groom/yummy/exception/UserErrorCode.java new file mode 100644 index 0000000..aaa0124 --- /dev/null +++ b/domain/src/main/java/com/groom/yummy/exception/UserErrorCode.java @@ -0,0 +1,18 @@ +package com.groom.yummy.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public enum UserErrorCode implements ErrorCode{ + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "유저가 존재하지 않습니다."), + ; + + private final HttpStatus code; + private final String message; + + UserErrorCode(HttpStatus code, String message) { + this.code = code; + this.message = message; + } +} diff --git a/domain/src/main/java/com/groom/yummy/exception/handler/CustomExceptionHandler.java b/domain/src/main/java/com/groom/yummy/exception/handler/CustomExceptionHandler.java new file mode 100644 index 0000000..987ccfc --- /dev/null +++ b/domain/src/main/java/com/groom/yummy/exception/handler/CustomExceptionHandler.java @@ -0,0 +1,29 @@ +package com.groom.yummy.exception.handler; + +import com.groom.yummy.dto.ResponseDto; +import com.groom.yummy.exception.CustomException; +import io.swagger.v3.oas.annotations.Hidden; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@Slf4j +@Hidden +@RestControllerAdvice +public class CustomExceptionHandler { + @ExceptionHandler(CustomException.class) + public ResponseEntity> CustomException(CustomException ex){ + log.error("커스텀 예외 발생 msg: {}", ex.getErrorCode().getMessage()); + return ResponseEntity.status(ex.getErrorCode().getCode()).body(ResponseDto.of(-1,ex.getErrorCode().getMessage())); + } + + // 기타예외 발생 시 500반환 + @ExceptionHandler + public ResponseEntity handleException(Exception ex) { + String message = "서버 내부에 에러가 발생했습니다."; + log.error(message+":"+ex.getMessage()+ex.getStackTrace()+ex.getCause()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new ResponseDto(-1,ex.getMessage())); + } +} diff --git a/domain/src/main/java/com/groom/yummy/group/Group.java b/domain/src/main/java/com/groom/yummy/group/Group.java new file mode 100755 index 0000000..a9a1293 --- /dev/null +++ b/domain/src/main/java/com/groom/yummy/group/Group.java @@ -0,0 +1,25 @@ +package com.groom.yummy.group; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Builder +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class Group { + + private Long id; + private String title; + private String content; + private Integer maxParticipants; + private Integer minParticipants; + private Integer currentParticipants; + private LocalDateTime meetingDate; + private MeetingStatus meetingStatus; + private Long storeId; +} diff --git a/domain/src/main/java/com/groom/yummy/group/GroupRepository.java b/domain/src/main/java/com/groom/yummy/group/GroupRepository.java new file mode 100755 index 0000000..cef353d --- /dev/null +++ b/domain/src/main/java/com/groom/yummy/group/GroupRepository.java @@ -0,0 +1,11 @@ +package com.groom.yummy.group; + +import java.util.List; +import java.util.Optional; + +public interface GroupRepository { + Optional findGroupById(Long id); + Long saveGroup(Group group); + List findAllGroups(String category, String regionCode, String storeName, int page); + void updateGroupParticipants(Long groupId, int newParticipantCount); +} diff --git a/domain/src/main/java/com/groom/yummy/group/GroupService.java b/domain/src/main/java/com/groom/yummy/group/GroupService.java new file mode 100755 index 0000000..877a63f --- /dev/null +++ b/domain/src/main/java/com/groom/yummy/group/GroupService.java @@ -0,0 +1,85 @@ +package com.groom.yummy.group; + +import com.groom.yummy.exception.CustomException; +import com.groom.yummy.exception.GroupErrorCode; +import com.groom.yummy.exception.StoreErrorCode; +import com.groom.yummy.exception.UserErrorCode; +import com.groom.yummy.store.Store; +import com.groom.yummy.store.StoreRepository; +import com.groom.yummy.user.User; +import com.groom.yummy.user.UserRepository; +import com.groom.yummy.usertogroup.AttendanceStatus; +import com.groom.yummy.usertogroup.UserToGroup; +import com.groom.yummy.usertogroup.UserToGroupRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class GroupService { + + private final GroupRepository groupRepository; + private final UserRepository userRepository; + private final UserToGroupRepository userToGroupRepository; + private final StoreRepository storeRepository; + + public Long createGroup(Long storeId, Long userId, String title, String content, Integer maxParticipants, Integer minParticipants, LocalDateTime meetingDate) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + + if (storeId == null) { + throw new CustomException(StoreErrorCode.STORE_NOT_FOUND); + } + + Group group = Group.builder() + .title(title) + .content(content) + .maxParticipants(maxParticipants) + .minParticipants(minParticipants) + .currentParticipants(1) + .meetingDate(meetingDate) + .meetingStatus(MeetingStatus.OPEN) + .storeId(storeId) + .build(); + + try { + Long groupId = groupRepository.saveGroup(group); + userToGroupRepository.saveUserToGroup(groupId, user.getId(), true, AttendanceStatus.APPROVED); + return groupId; + } catch (Exception e) { + throw new CustomException(GroupErrorCode.GROUP_CREATE_FAILED); + } + } + + public Optional findGroupById(Long id) { + return groupRepository.findGroupById(id); + } + + public List getAllGroups(String category, String regionCode, String storeName, int page) { + return groupRepository.findAllGroups(category, regionCode, storeName, page); + } + + public void joinGroup(Long groupId, Long userId, Long storeId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + + Group group = groupRepository.findGroupById(groupId) + .orElseThrow(() -> new CustomException(GroupErrorCode.GROUP_NOT_FOUND)); + +// Store store = storeRepository.findStoreById(storeId) +// .orElseThrow(() -> new CustomException(StoreErrorCode.STORE_NOT_FOUND)); + + if (group.getCurrentParticipants() >= group.getMaxParticipants()) { + throw new CustomException(GroupErrorCode.GROUP_PARTICIPATION_FULL); + } + groupRepository.updateGroupParticipants(groupId, group.getCurrentParticipants() + 1); + + userToGroupRepository.saveUserToGroup(group.getId(), user.getId(), false, AttendanceStatus.APPROVED); + } + +} + diff --git a/domain/src/main/java/com/groom/yummy/group/MeetingStatus.java b/domain/src/main/java/com/groom/yummy/group/MeetingStatus.java new file mode 100755 index 0000000..4908403 --- /dev/null +++ b/domain/src/main/java/com/groom/yummy/group/MeetingStatus.java @@ -0,0 +1,35 @@ +package com.groom.yummy.group; + +import lombok.Getter; + +@Getter +public enum MeetingStatus { + OPEN("OPEN", "모집 중: 참가 요청을 받고 있는 상태"), + CLOSED("CLOSED", "모집 마감: 더 이상 참가 요청을 받지 않는 상태."), + FINALIZED("FINALIZED", "팀 결성 완료: 모집이 끝나고 팀이 확정된 상태"), + CANCELLED("CANCELLED", "모집 취소: 소모임 자체가 취소된 상태"), + IN_PROGRESS("IN_PROGRESS", "모임 진행 중: 팀 결성 후 모임이 진행 중인 상태"), + COMPLETED("COMPLETED", "모임 완료: 모임이 종료된 상태"); + + private final String code; + private final String description; + + MeetingStatus(String code, String description) { + this.code = code; + this.description = description; + } + + @Override + public String toString() { + return String.format("%s (%s)", code, description); + } + + public static MeetingStatus fromCode(String code) { + for (MeetingStatus status : MeetingStatus.values()) { + if (status.getCode().equalsIgnoreCase(code)) { + return status; + } + } + throw new IllegalArgumentException("Unknown MeetingStatus code: " + code); + } +} diff --git a/domain/src/main/java/com/groom/yummy/group/dto/request/CreateGroupRequestDto.java b/domain/src/main/java/com/groom/yummy/group/dto/request/CreateGroupRequestDto.java new file mode 100755 index 0000000..d842b52 --- /dev/null +++ b/domain/src/main/java/com/groom/yummy/group/dto/request/CreateGroupRequestDto.java @@ -0,0 +1,32 @@ +package com.groom.yummy.group.dto.request; + +import com.groom.yummy.group.Group; +import lombok.*; + +import java.time.LocalDateTime; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CreateGroupRequestDto { + private Long storeId; + private String title; + private String content; + private Integer maxParticipants; + private Integer minParticipants; + private LocalDateTime meetingDate; + + public Group toGroupDomain() { + return Group.builder() + .title(this.title) + .content(this.content) + .maxParticipants(this.maxParticipants) + .minParticipants(this.minParticipants) + .meetingDate(this.meetingDate) + .storeId(this.storeId) + .build(); + } +} + diff --git a/domain/src/main/java/com/groom/yummy/group/dto/request/JoinGroupRequestDto.java b/domain/src/main/java/com/groom/yummy/group/dto/request/JoinGroupRequestDto.java new file mode 100755 index 0000000..142ad2b --- /dev/null +++ b/domain/src/main/java/com/groom/yummy/group/dto/request/JoinGroupRequestDto.java @@ -0,0 +1,13 @@ +package com.groom.yummy.group.dto.request; + +import lombok.*; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class JoinGroupRequestDto { + private Long storeId; +} + diff --git a/domain/src/main/java/com/groom/yummy/group/dto/response/GroupDetailResponseDto.java b/domain/src/main/java/com/groom/yummy/group/dto/response/GroupDetailResponseDto.java new file mode 100755 index 0000000..4b746f1 --- /dev/null +++ b/domain/src/main/java/com/groom/yummy/group/dto/response/GroupDetailResponseDto.java @@ -0,0 +1,45 @@ +package com.groom.yummy.group.dto.response; + +import com.groom.yummy.group.Group; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class GroupDetailResponseDto{ + + private Long id; + private String title; + private String content; + private Integer maxParticipants; + private Integer minParticipants; + private Integer currentParticipants; + private LocalDateTime meetingDate; + private String meetingStatus; + private Long storeId; + private LocalDateTime createdAt; + + private StoreDetailResponseDto storeDetailResponse; + private List participantResponseList; + + public static GroupDetailResponseDto fromGroupDomain(Group group) { + return GroupDetailResponseDto.builder() + .id(group.getId()) + .title(group.getTitle()) + .content(group.getContent()) + .maxParticipants(group.getMaxParticipants()) + .minParticipants(group.getMinParticipants()) + .currentParticipants(group.getCurrentParticipants()) + .meetingDate(group.getMeetingDate()) + .meetingStatus(group.getMeetingStatus().toString()) + .storeId(group.getStoreId()) + .createdAt(LocalDateTime.now()) + .build(); + } +} + diff --git a/domain/src/main/java/com/groom/yummy/group/dto/response/GroupResponseDto.java b/domain/src/main/java/com/groom/yummy/group/dto/response/GroupResponseDto.java new file mode 100755 index 0000000..95e34a3 --- /dev/null +++ b/domain/src/main/java/com/groom/yummy/group/dto/response/GroupResponseDto.java @@ -0,0 +1,40 @@ +package com.groom.yummy.group.dto.response; + +import com.groom.yummy.group.Group; +import lombok.*; + +import java.time.LocalDateTime; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class GroupResponseDto { + private Long id; + private String title; + private String content; + private Integer maxParticipants; + private Integer minParticipants; + private Integer currentParticipants; + private LocalDateTime meetingDate; + private String meetingStatus; + private Long storeId; + private LocalDateTime createdAt; + + public static GroupResponseDto fromGroupDomain(Group group) { + return GroupResponseDto.builder() + .id(group.getId()) + .title(group.getTitle()) + .content(group.getContent()) + .maxParticipants(group.getMaxParticipants()) + .minParticipants(group.getMinParticipants()) + .currentParticipants(group.getCurrentParticipants()) + .meetingDate(group.getMeetingDate()) + .meetingStatus(group.getMeetingStatus().toString()) + .storeId(group.getStoreId()) + .createdAt(LocalDateTime.now()) + .build(); + } +} + diff --git a/domain/src/main/java/com/groom/yummy/group/dto/response/ParticipantResponseDto.java b/domain/src/main/java/com/groom/yummy/group/dto/response/ParticipantResponseDto.java new file mode 100755 index 0000000..02d57e0 --- /dev/null +++ b/domain/src/main/java/com/groom/yummy/group/dto/response/ParticipantResponseDto.java @@ -0,0 +1,15 @@ +package com.groom.yummy.group.dto.response; + +import lombok.*; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ParticipantResponseDto { + private Long userId; + private String nickname; + private String attendanceStatus; + private Boolean isLeader; +} diff --git a/domain/src/main/java/com/groom/yummy/group/dto/response/StoreDetailResponseDto.java b/domain/src/main/java/com/groom/yummy/group/dto/response/StoreDetailResponseDto.java new file mode 100755 index 0000000..f65e126 --- /dev/null +++ b/domain/src/main/java/com/groom/yummy/group/dto/response/StoreDetailResponseDto.java @@ -0,0 +1,16 @@ +package com.groom.yummy.group.dto.response; + +import lombok.*; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class StoreDetailResponseDto { + private Long id; + private String name; + private String category; + private Long regionId; +} + diff --git a/domain/src/main/java/com/groom/yummy/publisher/EventPublisher.java b/domain/src/main/java/com/groom/yummy/publisher/EventPublisher.java new file mode 100644 index 0000000..7731c79 --- /dev/null +++ b/domain/src/main/java/com/groom/yummy/publisher/EventPublisher.java @@ -0,0 +1,5 @@ +package com.groom.yummy.publisher; + +public interface EventPublisher { + void publish(Object event); +} diff --git a/domain/src/main/java/com/groom/yummy/publisher/SpringEventPublisher.java b/domain/src/main/java/com/groom/yummy/publisher/SpringEventPublisher.java new file mode 100644 index 0000000..eb76ffa --- /dev/null +++ b/domain/src/main/java/com/groom/yummy/publisher/SpringEventPublisher.java @@ -0,0 +1,16 @@ +package com.groom.yummy.publisher; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class SpringEventPublisher implements EventPublisher{ + + private final ApplicationEventPublisher applicationEventPublisher; + @Override + public void publish(Object event) { + applicationEventPublisher.publishEvent(event); + } +} diff --git a/domain/src/main/java/com/groom/yummy/region/Region.java b/domain/src/main/java/com/groom/yummy/region/Region.java new file mode 100755 index 0000000..dab681e --- /dev/null +++ b/domain/src/main/java/com/groom/yummy/region/Region.java @@ -0,0 +1,17 @@ +package com.groom.yummy.region; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Getter +public class Region { + + private Long id; + private String regionName; + private String regionCode; +} diff --git a/domain/src/main/java/com/groom/yummy/region/RegionRepository.java b/domain/src/main/java/com/groom/yummy/region/RegionRepository.java new file mode 100755 index 0000000..f5de7ca --- /dev/null +++ b/domain/src/main/java/com/groom/yummy/region/RegionRepository.java @@ -0,0 +1,10 @@ +package com.groom.yummy.region; + +import java.util.List; +import java.util.Optional; + +public interface RegionRepository { + Optional findRegionById(Long id); + Long saveRegion(Region region); + List findAllRegions(); +} diff --git a/domain/src/main/java/com/groom/yummy/region/RegionService.java b/domain/src/main/java/com/groom/yummy/region/RegionService.java new file mode 100755 index 0000000..341113b --- /dev/null +++ b/domain/src/main/java/com/groom/yummy/region/RegionService.java @@ -0,0 +1,34 @@ +package com.groom.yummy.region; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class RegionService { + + private final RegionRepository regionRepository; + + public Optional findRegionById(Long id) { + return regionRepository.findRegionById(id); + } + + public Long createRegion(Region region) { + regionRepository.findAllRegions().stream() + .filter(r -> r.getRegionName().equals(region.getRegionName()) || r.getRegionCode().equals(region.getRegionCode())) + .findAny() + .ifPresent(r -> { + throw new IllegalArgumentException("이미 등록되어있는 지역입니다."); + }); + + return regionRepository.saveRegion(region); + } + + public List getAllRegions() { + return regionRepository.findAllRegions(); + } +} + diff --git a/domain/src/main/java/com/groom/yummy/reply/Reply.java b/domain/src/main/java/com/groom/yummy/reply/Reply.java new file mode 100644 index 0000000..92b0eb1 --- /dev/null +++ b/domain/src/main/java/com/groom/yummy/reply/Reply.java @@ -0,0 +1,35 @@ +package com.groom.yummy.reply; + +import java.time.LocalDateTime; + +import lombok.Builder; +import lombok.Getter; + +@Getter +public class Reply { + private Long id; + private String content; + private Long parentReplyId; + private Long userId; + private Long groupId; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + @Builder + public Reply(Long id, String content, Long parentReplyId, Long userId, Long groupId, LocalDateTime createdAt, + LocalDateTime updatedAt) { + this.id = id; + this.content = content; + this.parentReplyId = parentReplyId; + this.userId = userId; + this.groupId = groupId; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + protected void updateReply(String content) { + this.content = content; + this.updatedAt = LocalDateTime.now(); + } + +} \ No newline at end of file diff --git a/domain/src/main/java/com/groom/yummy/reply/ReplyRepository.java b/domain/src/main/java/com/groom/yummy/reply/ReplyRepository.java new file mode 100644 index 0000000..efb1e3a --- /dev/null +++ b/domain/src/main/java/com/groom/yummy/reply/ReplyRepository.java @@ -0,0 +1,14 @@ +package com.groom.yummy.reply; + +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface ReplyRepository { + Optional findById(Long id); + Optional save(Reply reply); + Page findAllByParentId(Long parentId, Pageable pageable); + Page findByGroupId(Long groupId, Pageable pageable); + void deleteById(Long id); +} \ No newline at end of file diff --git a/domain/src/main/java/com/groom/yummy/reply/ReplyService.java b/domain/src/main/java/com/groom/yummy/reply/ReplyService.java new file mode 100644 index 0000000..577056d --- /dev/null +++ b/domain/src/main/java/com/groom/yummy/reply/ReplyService.java @@ -0,0 +1,83 @@ +package com.groom.yummy.reply; + +import java.util.Optional; + +import com.groom.yummy.exception.CustomException; +import com.groom.yummy.exception.GroupErrorCode; +import com.groom.yummy.exception.ReplyErrorCode; +import com.groom.yummy.exception.UserErrorCode; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.groom.yummy.group.GroupRepository; +import com.groom.yummy.publisher.EventPublisher; +import com.groom.yummy.reply.event.ReplyUpdatedEvent; +import com.groom.yummy.user.UserRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class ReplyService { + + private final ReplyRepository replyRepository; + private final UserRepository userRepository; + private final GroupRepository groupRepository; + private final EventPublisher eventPublisher; + + @Transactional + public Reply createReply(Reply reply) { + validateUser(reply.getUserId()); + validateGroup(reply.getGroupId()); + + Reply savedReply = replyRepository.save(reply) + .orElseThrow(() -> new CustomException(ReplyErrorCode.REPLY_NOT_FOUND)); + + return savedReply; + } + + @Transactional + public Reply updateReply(Long id, String content) { + Reply reply = validateReply(id); + + // TODO: 댓글 수정 권한 + + reply.updateReply(content); + + eventPublisher.publish(new ReplyUpdatedEvent(reply.getId(), reply.getContent())); + + return reply; + } + + @Transactional(readOnly = true) + public Page getAllReplies(Long groupId, Pageable pageable) { + validateGroup(groupId); + return replyRepository.findByGroupId(groupId, pageable); + } + + @Transactional + public void deleteReply(Long id) { + validateReply(id); + replyRepository.deleteById(id); + } + + // 댓글 검증 + private Reply validateReply(Long id) { + return replyRepository.findById(id) + .orElseThrow(() -> new CustomException(ReplyErrorCode.REPLY_NOT_FOUND)); + } + + // 사용자 검증 + private void validateUser(Long userId) { + userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + } + + // 그룹 검증 + private void validateGroup(Long groupId) { + groupRepository.findGroupById(groupId) + .orElseThrow(() -> new CustomException(GroupErrorCode.GROUP_NOT_FOUND)); + } +} diff --git a/domain/src/main/java/com/groom/yummy/reply/event/ReplyUpdatedEvent.java b/domain/src/main/java/com/groom/yummy/reply/event/ReplyUpdatedEvent.java new file mode 100644 index 0000000..668c6be --- /dev/null +++ b/domain/src/main/java/com/groom/yummy/reply/event/ReplyUpdatedEvent.java @@ -0,0 +1,6 @@ +package com.groom.yummy.reply.event; + +public record ReplyUpdatedEvent ( + Long replyId, + String content +) {} diff --git a/domain/src/main/java/com/groom/yummy/store/Category_.java b/domain/src/main/java/com/groom/yummy/store/Category_.java new file mode 100644 index 0000000..2ed09a3 --- /dev/null +++ b/domain/src/main/java/com/groom/yummy/store/Category_.java @@ -0,0 +1,65 @@ +package com.groom.yummy.store; + +import lombok.Getter; + +import java.util.HashMap; +import java.util.Map; + +@Getter +public enum Category_ { + // TODO : apiCode 수정 + CHICKEN("001","치킨"), + CHINESE("002","중식"), + CUTLET_SASHIMI("003","돈까스-회"), + PIZZA("004","피자"), + FAST_FOOD("005","패스트푸드"), + STEW_SOUP("006","찜-탕"), + JOKBAL_BOSSAM("007","족발-보쌈"), + SNACK("008","분식"), + CAFE_DESSERT("009","카페-디저트"), + KOREAN("010","한식"), + MEAT("011","고기"), + WESTERN("012","양식"), + ASIAN("013","아시안"), + LATE_NIGHT("014","야식"), + LUNCH_BOX("015","도시락"); + + private final String apiCode; + private final String description; + + private static final Map API_CODE_TO_CATEGORY = new HashMap<>(); + + static { + for(Category_ category: values()){ + API_CODE_TO_CATEGORY.put(category.getApiCode(), category); + } + } + + Category_(String apiCode, String description) { + this.apiCode = apiCode; + this.description = description; + } + + public static Category_ fromApiCode(String apiCode) { + return API_CODE_TO_CATEGORY.get(apiCode); + } + + @Override + public String toString() { + return String.format("%s (%s) ", apiCode, description); + } + + public static boolean isValidCategory(Category_ category) { + return API_CODE_TO_CATEGORY.containsValue(category); + } + + public static Category_ fromDescription(String description) { + for (Category_ category : Category_.values()) { + if (category.getDescription().equals(description)) { + return category; + } + } + throw new IllegalArgumentException("잘못된 카테고리: " + description); + } + +} diff --git a/domain/src/main/java/com/groom/yummy/store/Store.java b/domain/src/main/java/com/groom/yummy/store/Store.java new file mode 100755 index 0000000..f3dffaa --- /dev/null +++ b/domain/src/main/java/com/groom/yummy/store/Store.java @@ -0,0 +1,19 @@ +package com.groom.yummy.store; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Builder +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class Store { + + private Long storeId; + private String name; + private Category_ category; + private Long regionId; + +} diff --git a/domain/src/main/java/com/groom/yummy/store/StoreRepository.java b/domain/src/main/java/com/groom/yummy/store/StoreRepository.java new file mode 100755 index 0000000..aae6f22 --- /dev/null +++ b/domain/src/main/java/com/groom/yummy/store/StoreRepository.java @@ -0,0 +1,12 @@ +package com.groom.yummy.store; + +import java.util.List; +import java.util.Optional; + +public interface StoreRepository { + Optional findStoreById(Long id); + Long saveStore(Store store, Long regionId); + List findAllStores(); + boolean existsByNameAndRegionId(String name, Long regionId); + +} diff --git a/domain/src/main/java/com/groom/yummy/store/StoreService.java b/domain/src/main/java/com/groom/yummy/store/StoreService.java new file mode 100755 index 0000000..a781a20 --- /dev/null +++ b/domain/src/main/java/com/groom/yummy/store/StoreService.java @@ -0,0 +1,62 @@ +package com.groom.yummy.store; + +import com.groom.yummy.region.RegionService; +import com.groom.yummy.store.dto.StoreApiResponseDto; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class StoreService { + + private final StoreRepository storeRepository; + private final RegionService regionService; + + public Long createStore(Store store) { + regionService.findRegionById(store.getRegionId()) + .orElseThrow(() -> new IllegalArgumentException("지역을 찾을 수 없습니다.")); + + storeRepository.findAllStores().stream() + .filter(s -> s.getName().equals(store.getName()) && s.getRegionId().equals(store.getRegionId())) + .findAny() + .ifPresent(s -> { + throw new IllegalArgumentException("이미 등록되어있는 가게 정보 입니다."); + }); + + if (store.getCategory() == null || !Category_.isValidCategory(store.getCategory())) { + throw new IllegalArgumentException("등록되지 않은 카테고리 입니다."); + } + + return storeRepository.saveStore(store, store.getRegionId()); + } + + public void saveStores(List storeDtos) { + List stores = storeDtos.stream() + .map(dto -> Store.builder() + .storeId(dto.getStoreId()) + .name(dto.getName()) + .category(Category_.fromApiCode(dto.getCategory())) + .regionId(dto.getRegionId()) + .build()) + .toList(); + + for (Store store : stores) { + boolean exists = storeRepository.existsByNameAndRegionId(store.getName(), store.getRegionId()); + if (!exists) { + storeRepository.saveStore(store, store.getRegionId()); + } + } + } + + public Optional findStoreById(Long storeId) { + return storeRepository.findStoreById(storeId); + } + + public List getAllStores() { + return storeRepository.findAllStores(); + } +} + diff --git a/domain/src/main/java/com/groom/yummy/store/dto/StoreApiResponseDto.java b/domain/src/main/java/com/groom/yummy/store/dto/StoreApiResponseDto.java new file mode 100644 index 0000000..37f20f7 --- /dev/null +++ b/domain/src/main/java/com/groom/yummy/store/dto/StoreApiResponseDto.java @@ -0,0 +1,27 @@ +package com.groom.yummy.store.dto; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.*; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class StoreApiResponseDto { + + private Long storeId; + private String name; + private String category; + private Long regionId; + + public static StoreApiResponseDto fromJsonNode(JsonNode node) { + return StoreApiResponseDto.builder() + .storeId(node.path("storeId").asLong()) + .name(node.path("name").asText()) + .category(node.path("category").asText()) + .regionId(node.path("regionId").asLong()) + .build(); + } + +} diff --git a/domain/src/main/java/com/groom/yummy/user/User.java b/domain/src/main/java/com/groom/yummy/user/User.java new file mode 100644 index 0000000..1f04a19 --- /dev/null +++ b/domain/src/main/java/com/groom/yummy/user/User.java @@ -0,0 +1,31 @@ +package com.groom.yummy.user; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Builder +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class User { + private Long id; + private String nickname; + private String role; + private String email; + private Long groupJoinCount; + private Long groupAttendanceCount; + private boolean isDeleted; + + protected void changeNickname(String nickname){ + if (nickname == null || nickname.trim().isEmpty()) { + throw new RuntimeException("닉네임은 null이거나 빈 값일 수 없습니다."); + } + this.nickname = nickname; + } + + protected void deleteUser(){ + this.isDeleted = true; + } +} diff --git a/domain/src/main/java/com/groom/yummy/user/UserAuthService.java b/domain/src/main/java/com/groom/yummy/user/UserAuthService.java new file mode 100644 index 0000000..02d366b --- /dev/null +++ b/domain/src/main/java/com/groom/yummy/user/UserAuthService.java @@ -0,0 +1,38 @@ +package com.groom.yummy.user; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserAuthService { + private final UserRepository userRepository; + + public Optional findAuthUserByEmail(String email){ + return userRepository.findByEmail(email); + } + + @Transactional + public User findOrCreateUser(Optional optionalUser, String nickname, String email){ + User user; + if(optionalUser.isEmpty()){ + user = User.builder() + .nickname(nickname) + .email(email) + .role("ROLE_USER") + .groupJoinCount(0L) + .groupAttendanceCount(0L) + .isDeleted(false) + .build(); + user = userRepository.save(user); + }else{ + user = optionalUser.get(); + } + return user; + } +} diff --git a/domain/src/main/java/com/groom/yummy/user/UserRepository.java b/domain/src/main/java/com/groom/yummy/user/UserRepository.java new file mode 100644 index 0000000..ad84ac2 --- /dev/null +++ b/domain/src/main/java/com/groom/yummy/user/UserRepository.java @@ -0,0 +1,9 @@ +package com.groom.yummy.user; + +import java.util.Optional; + +public interface UserRepository { + Optional findById(Long userId); + Optional findByEmail(String email); + User save(User user); +} diff --git a/domain/src/main/java/com/groom/yummy/user/UserService.java b/domain/src/main/java/com/groom/yummy/user/UserService.java new file mode 100644 index 0000000..cc8a078 --- /dev/null +++ b/domain/src/main/java/com/groom/yummy/user/UserService.java @@ -0,0 +1,41 @@ +package com.groom.yummy.user; + + +import com.groom.yummy.exception.CustomException; +import com.groom.yummy.exception.UserErrorCode; +import com.groom.yummy.publisher.EventPublisher; +import com.groom.yummy.user.event.UserDeleteEvent; +import com.groom.yummy.user.event.UserNicknameChangedEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Slf4j +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserService { + private final UserRepository userRepository; + private final EventPublisher eventPublisher; + + public User getUserInfo(Long userId) { + return userRepository.findById(userId).orElseThrow(()-> new CustomException(UserErrorCode.USER_NOT_FOUND)); + } + + @Transactional + public User updateNickname(Long userId, String nickname){ + User user = userRepository.findById(userId).orElseThrow(()-> new CustomException(UserErrorCode.USER_NOT_FOUND)); + user.changeNickname(nickname); + eventPublisher.publish(new UserNicknameChangedEvent(user.getId(),user.getNickname())); + return user; + } + + @Transactional + public Long deleteUser(Long userId) { + User user = userRepository.findById(userId).orElseThrow(()-> new CustomException(UserErrorCode.USER_NOT_FOUND)); + user.deleteUser(); + eventPublisher.publish(new UserDeleteEvent(user.getId(),user.isDeleted())); + return user.getId(); + } +} diff --git a/domain/src/main/java/com/groom/yummy/user/dto/request/UpdateNicknameReqDto.java b/domain/src/main/java/com/groom/yummy/user/dto/request/UpdateNicknameReqDto.java new file mode 100644 index 0000000..ff9592a --- /dev/null +++ b/domain/src/main/java/com/groom/yummy/user/dto/request/UpdateNicknameReqDto.java @@ -0,0 +1,15 @@ +package com.groom.yummy.user.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +import lombok.Builder; + +@Builder +public record UpdateNicknameReqDto( + + @Schema(description = "닉네임", example = "홍길동") + @NotBlank + + String nickname +){} \ No newline at end of file diff --git a/domain/src/main/java/com/groom/yummy/user/dto/response/UserInfoResDto.java b/domain/src/main/java/com/groom/yummy/user/dto/response/UserInfoResDto.java new file mode 100644 index 0000000..901ab6d --- /dev/null +++ b/domain/src/main/java/com/groom/yummy/user/dto/response/UserInfoResDto.java @@ -0,0 +1,23 @@ +package com.groom.yummy.user.dto.response; + +import com.groom.yummy.user.User; +import lombok.Builder; + +@Builder +public record UserInfoResDto( + Long id, + String email, + String nickname, + Long joinCount, + Long participationCount +) { + public static UserInfoResDto from(User user){ + return UserInfoResDto.builder() + .id(user.getId()) + .email(user.getEmail()) + .nickname(user.getNickname()) + .joinCount(user.getGroupJoinCount()) + .participationCount(user.getGroupAttendanceCount()) + .build(); + } +} diff --git a/domain/src/main/java/com/groom/yummy/user/event/UserDeleteEvent.java b/domain/src/main/java/com/groom/yummy/user/event/UserDeleteEvent.java new file mode 100644 index 0000000..33febc4 --- /dev/null +++ b/domain/src/main/java/com/groom/yummy/user/event/UserDeleteEvent.java @@ -0,0 +1,6 @@ +package com.groom.yummy.user.event; + +public record UserDeleteEvent( + Long userId, + boolean isDeleted +) {} diff --git a/domain/src/main/java/com/groom/yummy/user/event/UserNicknameChangedEvent.java b/domain/src/main/java/com/groom/yummy/user/event/UserNicknameChangedEvent.java new file mode 100644 index 0000000..c64b904 --- /dev/null +++ b/domain/src/main/java/com/groom/yummy/user/event/UserNicknameChangedEvent.java @@ -0,0 +1,6 @@ +package com.groom.yummy.user.event; + +public record UserNicknameChangedEvent( + Long userId, + String newNickname +){} diff --git a/domain/src/main/java/com/groom/yummy/user/facade/UserFacade.java b/domain/src/main/java/com/groom/yummy/user/facade/UserFacade.java new file mode 100644 index 0000000..0944334 --- /dev/null +++ b/domain/src/main/java/com/groom/yummy/user/facade/UserFacade.java @@ -0,0 +1,49 @@ +package com.groom.yummy.user.facade; + +import com.groom.yummy.user.dto.request.UpdateNicknameReqDto; +import com.groom.yummy.user.dto.response.UserInfoResDto; +import com.groom.yummy.user.User; +import com.groom.yummy.user.UserAuthService; +import com.groom.yummy.user.UserService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional(readOnly = true) // 서비스 계층과 충돌이 나지 않게 트랜잭션 선언 +public class UserFacade { + + private final UserAuthService userAuthService; + private final UserService userService; + + public UserInfoResDto getUserInfo(Long userId) { + User user = userService.getUserInfo(userId); + return UserInfoResDto.from(user); + } + public Optional findAuthUserByEmail(String email){ + return userAuthService.findAuthUserByEmail(email); + } + + @Transactional + public User findOrCreateUser(Optional optionalUser, String name, String email) { + return userAuthService.findOrCreateUser(optionalUser, name, email); + } + + @Transactional + public UserInfoResDto updateUserNickname(Long id, UpdateNicknameReqDto updateNicknameReqDto){ + String nickname = updateNicknameReqDto.nickname(); + User user = userService.updateNickname(id, nickname); + return UserInfoResDto.from(user); + } + + @Transactional + public Long deleteUser(Long userId) { + Long deleteUserId = userService.deleteUser(userId); + return deleteUserId; + } +} diff --git a/domain/src/main/java/com/groom/yummy/usertogroup/AttendanceStatus.java b/domain/src/main/java/com/groom/yummy/usertogroup/AttendanceStatus.java new file mode 100644 index 0000000..43cc13b --- /dev/null +++ b/domain/src/main/java/com/groom/yummy/usertogroup/AttendanceStatus.java @@ -0,0 +1,17 @@ +package com.groom.yummy.usertogroup; + +public enum AttendanceStatus { + ATTENDED("참석"), + NO_SHOW("불참"), + APPROVED("승인됨"); + + private final String description; + + AttendanceStatus(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } +} diff --git a/domain/src/main/java/com/groom/yummy/usertogroup/UserToGroup.java b/domain/src/main/java/com/groom/yummy/usertogroup/UserToGroup.java new file mode 100644 index 0000000..34f9bfd --- /dev/null +++ b/domain/src/main/java/com/groom/yummy/usertogroup/UserToGroup.java @@ -0,0 +1,16 @@ +package com.groom.yummy.usertogroup; + +import com.groom.yummy.group.Group; +import com.groom.yummy.user.User; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class UserToGroup { + private Long id; + private Group group; + private User user; + private boolean isLeader; + private AttendanceStatus attendanceStatus; +} diff --git a/domain/src/main/java/com/groom/yummy/usertogroup/UserToGroupRepository.java b/domain/src/main/java/com/groom/yummy/usertogroup/UserToGroupRepository.java new file mode 100644 index 0000000..633e0c2 --- /dev/null +++ b/domain/src/main/java/com/groom/yummy/usertogroup/UserToGroupRepository.java @@ -0,0 +1,13 @@ +package com.groom.yummy.usertogroup; + +import com.groom.yummy.group.Group; +import com.groom.yummy.user.User; + +import java.util.Optional; + +public interface UserToGroupRepository { + void saveUserToGroup(Long groupId, Long userId, boolean isLeader, AttendanceStatus attendanceStatus); + Optional findByGroupAndUser(Group group, User user); +} + + diff --git a/domain/src/test/java/com/groom/yummy/facade/UserFacadeTest.java b/domain/src/test/java/com/groom/yummy/facade/UserFacadeTest.java new file mode 100644 index 0000000..0d5565c --- /dev/null +++ b/domain/src/test/java/com/groom/yummy/facade/UserFacadeTest.java @@ -0,0 +1,128 @@ +package com.groom.yummy.facade; + +import com.groom.yummy.user.User; +import com.groom.yummy.user.UserAuthService; +import com.groom.yummy.user.UserService; +import com.groom.yummy.user.dto.request.UpdateNicknameReqDto; +import com.groom.yummy.user.dto.response.UserInfoResDto; + +import com.groom.yummy.user.facade.UserFacade; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class UserFacadeTest { + @InjectMocks + private UserFacade userFacade; + @Mock + private UserAuthService userAuthService; + @Mock + private UserService userService; + + @Test + @DisplayName("유저 서비스에서 조회한 유저를 dto로 변환하여 리턴합니다.") + + void getUserInfoTest() { + // given + Long userId = 1L; + String nickname = "강형준"; + String email = "email@gmail.com"; + + User mockUser = User.builder().id(userId).nickname(nickname).email(email).build(); + when(userService.getUserInfo(userId)).thenReturn(mockUser); + + // when + UserInfoResDto result = userFacade.getUserInfo(userId); + + // then + assertNotNull(result); + assertEquals(mockUser.getEmail(), result.email()); + assertEquals(mockUser.getNickname(), result.nickname()); + } + + @Test + @DisplayName("email로 조회한 User를 리턴합니다.") + + void findAuthUserByEmailTest() { + // given + Long userId = 1L; + String nickname = "강형준"; + String email = "email@gmail.com"; + + User mockUser = User.builder().id(userId).nickname(nickname).email(email).build(); + when(userAuthService.findAuthUserByEmail(email)).thenReturn(Optional.of(mockUser)); + + // when + Optional result = userFacade.findAuthUserByEmail(email); + + // then + assertTrue(result.isPresent()); + assertEquals(email, result.get().getEmail()); + } + + @Test + @DisplayName("유저 회원가입 결과를 받아 리턴합니다.") + + void findOrCreateUserTest() { + // given + Long userId = 1L; + String nickname = "강형준"; + String email = "email@gmail.com"; + Optional optionalUser = Optional.empty(); + User mockUser = User.builder().id(userId).nickname(nickname).email(email).build(); + when(userAuthService.findOrCreateUser(optionalUser, nickname, email)).thenReturn(mockUser); + + // when + User result = userFacade.findOrCreateUser(optionalUser, nickname, email); + + // then + assertNotNull(result); + assertEquals(email, result.getEmail()); + assertEquals(nickname, result.getNickname()); + } + + @Test + @DisplayName("유저 업데이트한 결과를 dto로 변환하여 리턴합니다.") + + void updateUserNicknameTest() { + // Arrange + Long userId = 1L; + String email = "email@gmail.com"; + String newNickname = "업데이트강형준"; + UpdateNicknameReqDto updateNicknameReqDto = new UpdateNicknameReqDto(newNickname); + User updateMockUser = User.builder().id(userId).nickname(newNickname).email(email).build(); + when(userService.updateNickname(userId, newNickname)).thenReturn(updateMockUser); + + // when + UserInfoResDto result = userFacade.updateUserNickname(userId, updateNicknameReqDto); + + // then + assertNotNull(result); + assertEquals(newNickname, result.nickname()); + } + + @Test + @DisplayName("유저 삭제(논리) 후 삭제한 userID를 리턴합니다.") + void deleteUserTest() { + + // given + Long userId = 1L; + when(userService.deleteUser(userId)).thenReturn(userId); + + // when + Long result = userFacade.deleteUser(userId); + + // then + assertNotNull(result); + assertEquals(userId, result); + } +} \ No newline at end of file diff --git a/domain/src/test/java/com/groom/yummy/group/GroupServiceTest.java b/domain/src/test/java/com/groom/yummy/group/GroupServiceTest.java new file mode 100755 index 0000000..ec97755 --- /dev/null +++ b/domain/src/test/java/com/groom/yummy/group/GroupServiceTest.java @@ -0,0 +1,224 @@ +package com.groom.yummy.group; + +import com.groom.yummy.exception.CustomException; +import com.groom.yummy.store.Store; +import com.groom.yummy.store.StoreRepository; +import com.groom.yummy.user.User; +import com.groom.yummy.user.UserRepository; +import com.groom.yummy.usertogroup.AttendanceStatus; +import com.groom.yummy.usertogroup.UserToGroupRepository; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class GroupServiceTest { + + @InjectMocks + private GroupService groupService; + + @Mock + private GroupRepository groupRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private UserToGroupRepository userToGroupRepository; + + @Mock + private StoreRepository storeRepository; + + @DisplayName("User can create a group.") + @Order(1) + @Test + void createGroupTest() { + // Given + Long storeId = 10L; + Long userId = 1L; + String title = "Go to Goorm store"; + String content = "yummy yummy yummy yummy yummy"; + Integer maxParticipants = 4; + Integer minParticipants = 3; + LocalDateTime meetingDate = LocalDateTime.now(); + + User user = User.builder() + .id(userId) + .email("Goorm@gmail.com") + .nickname("Goorm") + .role("USER") + .isDeleted(false) + .build(); + + when(userRepository.findById(userId)) + .thenReturn(Optional.of(user)); + when(groupRepository.saveGroup(any(Group.class))) + .thenReturn(1L); + + // When + Long groupId = groupService.createGroup(storeId, userId, title, content, maxParticipants, minParticipants, meetingDate); + + // Then + assertEquals(1L, groupId); + verify(groupRepository, times(1)) + .saveGroup(any(Group.class)); + verify(userToGroupRepository, times(1)) + .saveUserToGroup(any(Long.class), any(Long.class), eq(true), eq(AttendanceStatus.APPROVED)); + } + + + @DisplayName("User can view group details.") + @Order(2) + @Test + void viewGroupDetailsTest() { + // Given + Long groupId = 1L; + Group group = Group.builder() + .id(groupId) + .title("Go to Goorm store") + .content("yummy yummy") + .maxParticipants(4) + .minParticipants(3) + .currentParticipants(2) + .storeId(10L) + .build(); + + when(groupRepository.findGroupById(groupId)).thenReturn(Optional.of(group)); + + // When + Optional foundGroup = groupService.findGroupById(groupId); + + // Then + assertTrue(foundGroup.isPresent()); + assertEquals(groupId, foundGroup.get().getId()); + verify(groupRepository, times(1)).findGroupById(groupId); + } + + @DisplayName("User can join a group.") + @Order(3) + @Test + void joinGroupTest() { + // Given + Long groupId = 1L; + Long userId = 1L; + Long storeId = 10L; + + Group group = Group.builder() + .id(groupId) + .title("Go to Goorm store") + .content("yummy yummy") + .maxParticipants(4) + .minParticipants(3) + .currentParticipants(2) + .storeId(storeId) + .build(); + + User user = User.builder() + .id(userId) + .email("Goorm@gmail.com") + .nickname("Goorm") + .role("USER") + .isDeleted(false) + .build(); + + Store store = new Store(); // Store 객체 생성 + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(groupRepository.findGroupById(groupId)).thenReturn(Optional.of(group)); + + // Mock UserToGroupRepository 동작 설정 + doNothing().when(userToGroupRepository) + .saveUserToGroup(eq(groupId), eq(userId), eq(false), eq(AttendanceStatus.APPROVED)); + + // When + groupService.joinGroup(groupId, userId, storeId); + + // Then + verify(groupRepository, times(1)).updateGroupParticipants(eq(groupId), eq(3)); + verify(userToGroupRepository, times(1)) + .saveUserToGroup(eq(groupId), eq(userId), eq(false), eq(AttendanceStatus.APPROVED)); + } + + @DisplayName("Group join fails when participant limit exceeded.") + @Order(4) + @Test + void joinGroupParticipantLimitExceededTest() { + // Given + Long groupId = 1L; + Long userId = 1L; + Long storeId = 10L; + + Group group = Group.builder() + .id(groupId) + .title("Go to Goorm store") + .content("yummy yummy") + .maxParticipants(4) + .currentParticipants(4) // 참여 인원 초과 + .storeId(storeId) + .build(); + + User user = User.builder() + .id(userId) + .email("Goorm@gmail.com") + .nickname("Goorm") + .build(); + + Store store = new Store(); // Store 객체 생성 + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(groupRepository.findGroupById(groupId)).thenReturn(Optional.of(group)); + + // When + CustomException exception = assertThrows(CustomException.class, + () -> groupService.joinGroup(groupId, userId, storeId)); + + // Then + assertEquals("참가 인원이 가득 찼습니다.", exception.getMessage()); + verify(groupRepository, never()).updateGroupParticipants(anyLong(), anyInt()); + } + + + @DisplayName("Retrieve group list test.") + @Order(5) + @Test + void retrieveGroupListTest() { + // Given + String category = "KOREAN"; + String regionCode = "SEOUL"; + String storeName = "Goorm Store"; + int page = 1; + + List groups = List.of( + Group.builder() + .id(1L) + .title("Go to Goorm store") + .content("yummy yummy") + .maxParticipants(4) + .currentParticipants(2) + .storeId(10L) + .build() + ); + + when(groupRepository.findAllGroups(category, regionCode, storeName, page)).thenReturn(groups); + + // When + List result = groupService.getAllGroups(category, regionCode, storeName, page); + + // Then + assertEquals(1, result.size()); + assertEquals("Go to Goorm store", result.get(0).getTitle()); + verify(groupRepository, times(1)).findAllGroups(category, regionCode, storeName, page); + } +} diff --git a/domain/src/test/java/com/groom/yummy/region/RegionServiceTest.java b/domain/src/test/java/com/groom/yummy/region/RegionServiceTest.java new file mode 100755 index 0000000..8924b6a --- /dev/null +++ b/domain/src/test/java/com/groom/yummy/region/RegionServiceTest.java @@ -0,0 +1,65 @@ +package com.groom.yummy.region; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +public class RegionServiceTest { + + @Mock + private RegionRepository regionRepository; + + @InjectMocks + private RegionService regionService; + + + @Test + @Order(1) + @DisplayName("지역 저장 성공 테스트") + void 지역_저장_성공_테스트() { + //Given + Region region = Region.builder() + .regionName("구름동") + .regionCode("99999999") + .build(); + + when(regionRepository.findAllRegions()).thenReturn(List.of()); + when(regionRepository.saveRegion(any(Region.class))).thenReturn(1L); + + // When + Long regionId = regionService.createRegion(region); + + // Then + assertThat(regionId).isEqualTo(1L); + verify(regionRepository).saveRegion(any(Region.class)); + } + + @Test + @Order(2) + @DisplayName("중복된 지역 정보로 저장 시 실패") + void 중복_자역_저장_실패_테스트() { + // Given + Region region = Region.builder() + .regionName("서울특별시 강남구") + .regionCode("1111010100") + .build(); + + when(regionRepository.findAllRegions()).thenReturn(List.of(region)); + + // When & Then + assertThrows(IllegalArgumentException.class, () -> regionService.createRegion(region)); + verify(regionRepository, never()).saveRegion(any(Region.class)); + } +} diff --git a/domain/src/test/java/com/groom/yummy/reply/ReplyServiceTest.java b/domain/src/test/java/com/groom/yummy/reply/ReplyServiceTest.java new file mode 100644 index 0000000..2586322 --- /dev/null +++ b/domain/src/test/java/com/groom/yummy/reply/ReplyServiceTest.java @@ -0,0 +1,172 @@ +package com.groom.yummy.reply; + +import com.groom.yummy.exception.CustomException; +import com.groom.yummy.publisher.EventPublisher; +import com.groom.yummy.reply.event.ReplyUpdatedEvent; +import com.groom.yummy.user.User; +import com.groom.yummy.user.UserRepository; +import com.groom.yummy.group.Group; +import com.groom.yummy.group.GroupRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ReplyServiceTest { + + @InjectMocks + private ReplyService replyService; + + @Mock + private ReplyRepository replyRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private GroupRepository groupRepository; + + @Mock + private EventPublisher eventPublisher; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + @DisplayName("사용자는 소모임에 댓그을 등록할 수 있다.") + void createReply_Success() { + // Given + Reply reply = Reply.builder().content("댓글 내용").userId(1L).groupId(1L).build(); + Reply savedReply = Reply.builder().id(1L).content("댓글 내용").userId(1L).groupId(1L).build(); + + when(userRepository.findById(1L)).thenReturn(Optional.of(mock(User.class))); + when(groupRepository.findGroupById(1L)).thenReturn(Optional.of(mock(Group.class))); + when(replyRepository.save(reply)).thenReturn(Optional.of(savedReply)); + + // When + Reply result = replyService.createReply(reply); + + // Then + assertNotNull(result); + assertEquals(savedReply.getId(), result.getId()); + verify(replyRepository, times(1)).save(reply); + } + + @Test + @DisplayName("존재하지 않는 사용자는 댓글을 등록할 수 없다.") + void createReply_UserNotFound() { + // Given + Reply reply = Reply.builder().content("댓글 내용").userId(1L).groupId(1L).build(); + + when(userRepository.findById(1L)).thenReturn(Optional.empty()); + + // When & Then + CustomException exception = assertThrows(CustomException.class, () -> { + replyService.createReply(reply); + }); + + assertEquals("유저가 존재하지 않습니다.", exception.getMessage()); + verify(replyRepository, never()).save(any()); + } + + @Test + @DisplayName("댓글을 수정할 수 있다.") + void updateReply_Success() { + // Given + Reply reply = Reply.builder().id(1L).content("기존 댓글 내용").build(); + String updatedContent = "수정된 댓글 내용"; + + when(replyRepository.findById(1L)).thenReturn(Optional.of(reply)); + + // When + Reply result = replyService.updateReply(1L, updatedContent); + + // Then + assertNotNull(result); + assertEquals(updatedContent, result.getContent()); + verify(replyRepository, times(1)).findById(1L); + verify(eventPublisher, times(1)).publish(any(ReplyUpdatedEvent.class)); + } + + @Test + @DisplayName("존재하지 않는 댓글은 수정할 수 없다.") + void updateReply_ReplyNotFound() { + // Given + when(replyRepository.findById(1L)).thenReturn(Optional.empty()); + + // When & Then + CustomException exception = assertThrows(CustomException.class, () -> { + replyService.updateReply(1L, "수정된 댓글 내용"); + }); + + assertEquals("댓글이 존재하지 않습니다.", exception.getMessage()); + verify(eventPublisher, never()).publish(any()); + } + + @Test + @DisplayName("소모임에 달리 모든 댓글을 조회할 수 있다.") + void getAllReplies_Success() { + // Given + Long groupId = 1L; + Pageable pageable = PageRequest.of(0, 10); + Reply reply1 = Reply.builder().id(1L).content("댓글 1").groupId(groupId).build(); + Reply reply2 = Reply.builder().id(2L).content("댓글 2").groupId(groupId).build(); + Page replyPage = new PageImpl<>(List.of(reply1, reply2)); + + when(groupRepository.findGroupById(groupId)).thenReturn(Optional.of(mock(Group.class))); + when(replyRepository.findByGroupId(eq(groupId), eq(pageable))).thenReturn(replyPage); + + // When + Page result = replyService.getAllReplies(groupId, pageable); + + // Then + assertNotNull(result); + assertEquals(2, result.getTotalElements()); + verify(replyRepository, times(1)).findByGroupId(eq(groupId), eq(pageable)); + } + + @Test + @DisplayName("댓글을 삭제할 수 있다.") + void deleteReply_Success() { + // Given + Long replyId = 1L; + Reply reply = Reply.builder().id(replyId).content("댓글 내용").build(); + + when(replyRepository.findById(replyId)).thenReturn(Optional.of(reply)); + + // When + replyService.deleteReply(replyId); + + // Then + verify(replyRepository, times(1)).deleteById(replyId); + } + + @Test + @DisplayName("존재하지 않는 댓글은 삭제할 수 없다.") + void deleteReply_ReplyNotFound() { + // Given + when(replyRepository.findById(1L)).thenReturn(Optional.empty()); + + // When & Then + CustomException exception = assertThrows(CustomException.class, () -> { + replyService.deleteReply(1L); + }); + + assertEquals("댓글이 존재하지 않습니다.", exception.getMessage()); + verify(replyRepository, never()).deleteById(any()); + } +} \ No newline at end of file diff --git a/domain/src/test/java/com/groom/yummy/store/StoreServiceTest.java b/domain/src/test/java/com/groom/yummy/store/StoreServiceTest.java new file mode 100755 index 0000000..5389e33 --- /dev/null +++ b/domain/src/test/java/com/groom/yummy/store/StoreServiceTest.java @@ -0,0 +1,77 @@ +package com.groom.yummy.store; + +import com.groom.yummy.region.Region; +import com.groom.yummy.region.RegionService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +public class StoreServiceTest { + + @Mock + private StoreRepository storeRepository; + + @Mock + private RegionService regionService; + + @InjectMocks + private StoreService storeService; + + + @Test + @Order(1) + @DisplayName("요청 API 가게 정보 등록 성공 테스트") + void 가게_등록_성공_테스트() { + // Given + Store store = Store.builder() + .name("GoormStore") + .category(Category_.PIZZA) + .regionId(1L) + .build(); + + Region region = Region.builder() + .id(1L) + .regionName("구름동") + .regionCode("99999999") + .build(); + + when(regionService.findRegionById(1L)).thenReturn(Optional.of(region)); + when(storeRepository.saveStore(any(Store.class), any(Long.class))).thenReturn(1L); + + // When + Long storeId = storeService.createStore(store); + + // Then + assertThat(storeId).isEqualTo(1L); + verify(regionService).findRegionById(1L); + verify(storeRepository).saveStore(any(Store.class), eq(1L)); + } + + @Test + @Order(2) + @DisplayName("요청 API 가게 정보 등록 실패 테스트") + void 가게_등록_실패_테스트() { + // GIVEN + Store store = Store.builder() + .name("GoormStore") + .category(Category_.PIZZA) + .regionId(1L) + .build(); + + // When & Then + assertThrows(IllegalArgumentException.class, () -> storeService.createStore(store)); + verify(storeRepository, never()).saveStore(any(Store.class), anyLong()); + } +} diff --git a/domain/src/test/java/com/groom/yummy/user/UserAuthServiceTest.java b/domain/src/test/java/com/groom/yummy/user/UserAuthServiceTest.java new file mode 100644 index 0000000..5d90cc3 --- /dev/null +++ b/domain/src/test/java/com/groom/yummy/user/UserAuthServiceTest.java @@ -0,0 +1,107 @@ +package com.groom.yummy.user; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class UserAuthServiceTest { + @InjectMocks + private UserAuthService userAuthService; + @Mock + private UserRepository userRepository; + + @Test + @DisplayName("회원가입이 된 유저인지 확인하기 위해 email로 유저를 조회합니다.") + void findAuthUserByEmailTest() { + // given + String nickname = "강형준"; + String email = "email@gmail.com"; + String role = "ROLE_USER"; + User mockUser = User.builder() + .email(email) + .nickname(nickname) + .role(role) + .build(); + when(userRepository.findByEmail(email)).thenReturn(Optional.of(mockUser)); + + // when + Optional result = userAuthService.findAuthUserByEmail(email); + + // then + assertTrue(result.isPresent()); + assertEquals(email, result.get().getEmail()); + assertEquals(nickname, result.get().getNickname()); + } + + @Test + @DisplayName("회원가입 하지 않은 유저를 조회합니다.") + void findAuthUserByEmailTest_Empty() { + // given + String email = "email@gmail.com"; + when(userRepository.findByEmail(email)).thenReturn(Optional.empty()); + + // when + Optional result = userAuthService.findAuthUserByEmail(email); + + // then + assertTrue(result.isEmpty()); + } + + @Test + @DisplayName("유저가 없는 경우(Optional.empty()) 회원가입을 진행합니다.") + void findOrCreateUser_Create_Test() { + // given + Long userId = 1L; + String nickname = "강형준"; + String email = "email@gmail.com"; + String role = "ROLE_USER"; + Optional optionalUser = Optional.empty(); + + User createUser = User.builder().id(userId).email(email).nickname(nickname).role(role).build(); + when(userRepository.save(any(User.class))).thenReturn(createUser); + + // when + User result = userAuthService.findOrCreateUser(optionalUser, nickname, email); + + // then + assertNotNull(result); + assertEquals(userId, result.getId()); + assertEquals(email, result.getEmail()); + assertEquals(nickname, result.getNickname()); + assertEquals(role, result.getRole()); + } + + @Test + @DisplayName("유저가 있는 경우 해당 유저를 리턴합니다.") + void findOrCreateUser_Find_Test() { + // given + String nickname = "강형준"; + String email = "email@gmail.com"; + String role = "ROLE_USER"; + + User existingUser = User.builder() + .email(email) + .nickname(nickname) + .role(role) + .build(); + + Optional optionalUser = Optional.of(existingUser); + + // when + User result = userAuthService.findOrCreateUser(optionalUser, nickname, email); + + // then + assertNotNull(result); + assertEquals(existingUser, result); + } +} \ No newline at end of file diff --git a/domain/src/test/java/com/groom/yummy/user/UserServiceTest.java b/domain/src/test/java/com/groom/yummy/user/UserServiceTest.java new file mode 100644 index 0000000..17426ec --- /dev/null +++ b/domain/src/test/java/com/groom/yummy/user/UserServiceTest.java @@ -0,0 +1,120 @@ +package com.groom.yummy.user; + +import com.groom.yummy.exception.CustomException; +import com.groom.yummy.exception.UserErrorCode; +import com.groom.yummy.publisher.EventPublisher; +import com.groom.yummy.user.event.UserDeleteEvent; +import com.groom.yummy.user.event.UserNicknameChangedEvent; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +public class UserServiceTest { + @InjectMocks + private UserService userService; + + @Mock + private UserRepository userRepository; + + @Mock + private EventPublisher eventPublisher; + + @Test + @DisplayName("유저를 조회합니다.") + void getUserInfoTest() { + // given + Long userId = 1L; + String email = "email@gmail.com"; + String nickname = "깅형준"; + User mockUser = User.builder().id(userId).email(email).nickname(nickname).build(); + when(userRepository.findById(userId)).thenReturn(Optional.of(mockUser)); + + // when + User result = userService.getUserInfo(userId); + + // then + assertNotNull(result); + assertEquals(userId, result.getId()); + assertEquals(email, result.getEmail()); + assertEquals(nickname, result.getNickname()); + verify(userRepository, times(1)).findById(userId); + } + + @Test + @DisplayName("유저 조회 결과가 Optional.empty 이면 예외가 발생합니다.") + void getUserInfoFailTest() { + // when + Long userId = 1L; + when(userRepository.findById(userId)).thenReturn(Optional.empty()); + + // when&then + CustomException exception = assertThrows(CustomException.class, () -> userService.getUserInfo(userId)); + assertEquals(UserErrorCode.USER_NOT_FOUND, exception.getErrorCode()); + verify(userRepository, times(1)).findById(userId); + } + + @Test + @DisplayName("유저의 닉네임을 변경합니다.") + void updateNicknameTest() { + // given + Long userId = 1L; + String email = "email@gmail.com"; + String oldNickname = "강형준"; + String newNickname = "newNickname"; + User mockUser = spy(User.builder().id(userId).email(email).nickname(oldNickname).build()); + when(userRepository.findById(userId)).thenReturn(Optional.of(mockUser)); + + // when + User result = userService.updateNickname(userId, newNickname); + + // then + assertNotNull(result); + assertEquals(newNickname, result.getNickname()); + verify(mockUser, times(1)).changeNickname(newNickname); + verify(eventPublisher, times(1)).publish(any(UserNicknameChangedEvent.class)); + } + + @Test + @DisplayName("유저를 삭제(논리) 합니다.") + void deleteUserTest() { + // given + Long userId = 1L; + String email = "email@gmail.com"; + String nickname = "강형준"; + + User mockUser = spy(User.builder().id(userId).email(email).nickname(nickname).build()); + when(userRepository.findById(userId)).thenReturn(Optional.of(mockUser)); + + // when + Long result = userService.deleteUser(userId); + + // then + assertNotNull(result); + assertEquals(userId, result); + verify(mockUser, times(1)).deleteUser(); + verify(eventPublisher, times(1)).publish(any(UserDeleteEvent.class)); + } + + @Test + @DisplayName("없는 유저를 삭제하면 예외가 발생합니다.") + void deleteUserFailTest() { + // given + Long userId = 1L; + when(userRepository.findById(userId)).thenReturn(Optional.empty()); + + // when&then + CustomException exception = assertThrows(CustomException.class, () -> userService.deleteUser(userId)); + assertEquals(UserErrorCode.USER_NOT_FOUND, exception.getErrorCode()); + verify(userRepository, times(1)).findById(userId); + } + +} diff --git a/settings.gradle b/settings.gradle index 06650c3..7f89c0b 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,10 +1,2 @@ rootProject.name = 'famous_restaurant' -include 'group' -include 'region' -include 'store' -include 'application' -include 'storage' -include 'user' -include 'common' -include 'domain' - +include 'application', 'storage', 'common', 'domain' diff --git a/storage/build.gradle b/storage/build.gradle index e711040..a00aaf3 100644 --- a/storage/build.gradle +++ b/storage/build.gradle @@ -4,23 +4,40 @@ plugins { id 'io.spring.dependency-management' version '1.1.7' } -group = 'com.goorm' -version = 'unspecified' - -java { - toolchain { - languageVersion = JavaLanguageVersion.of(17) - } -} +version = '1.0.0' dependencies { + // 모듈 간 의존성 + implementation project(':domain') + compileOnly project(':common') + testImplementation project(':common') - implementation 'org.projectlombok:lombok' - annotationProcessor 'org.projectlombok:lombok' - + // Spring Boot implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-web' + // Database runtimeOnly 'com.mysql:mysql-connector-j' + runtimeOnly 'com.h2database:h2' + + // Lombok + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + // Testing + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation platform('org.junit:junit-bom:5.10.0') + testImplementation 'org.junit.jupiter:junit-jupiter' +} +bootJar { + enabled = false } +//jar { +// enabled = false +//} + +test { + useJUnitPlatform() +} diff --git a/storage/src/main/java/com/groom/yummy/config/H2Config.java b/storage/src/main/java/com/groom/yummy/config/H2Config.java new file mode 100644 index 0000000..882f766 --- /dev/null +++ b/storage/src/main/java/com/groom/yummy/config/H2Config.java @@ -0,0 +1,58 @@ +//package com.groom.yummy.config; +// +//import jakarta.persistence.EntityManagerFactory; +//import org.springframework.beans.factory.annotation.Qualifier; +//import org.springframework.boot.jdbc.DataSourceBuilder; +//import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder; +//import org.springframework.context.annotation.Bean; +//import org.springframework.context.annotation.Configuration; +//import org.springframework.context.annotation.Primary; +//import org.springframework.context.annotation.Profile; +//import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +//import org.springframework.orm.jpa.JpaTransactionManager; +//import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; +//import org.springframework.transaction.PlatformTransactionManager; +//import org.springframework.transaction.annotation.EnableTransactionManagement; +// +//import javax.sql.DataSource; +//import java.util.HashMap; +//import java.util.Map; +// +//@Configuration +//@Profile("test") +//@EnableTransactionManagement +//@EnableJpaRepositories(basePackages = {"com.groom.yummy.domain"}) +//public class H2Config { +// @Bean(name = "dataSource") +// @Primary +// public DataSource h2DataSource() { +// return DataSourceBuilder.create() +// .driverClassName("org.h2.Driver") +// .url("jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") +// .username("sa") +// .password("") +// .build(); +// } +// +// @Primary +// @Bean(name = "entityManagerFactory") +// public LocalContainerEntityManagerFactoryBean entityManagerFactory(EntityManagerFactoryBuilder builder, +// @Qualifier("dataSource") DataSource dataSource) { +// Map properties = new HashMap<>(); +// properties.put("hibernate.hbm2ddl.auto", "create"); +// properties.put("hibernate.dialect", "org.hibernate.dialect.H2Dialect"); +// +// return builder +// .dataSource(dataSource) +// .packages("com.groom.yummy.domain") +// .persistenceUnit("test") +// .properties(properties) +// .build(); +// } +// +// @Primary +// @Bean(name = "transactionManager") +// public PlatformTransactionManager transactionManager(@Qualifier("entityManagerFactory") EntityManagerFactory entityManagerFactory) { +// return new JpaTransactionManager(entityManagerFactory); +// } +//} diff --git a/storage/src/main/java/com/groom/yummy/config/MySqlConfig.java b/storage/src/main/java/com/groom/yummy/config/MySqlConfig.java new file mode 100644 index 0000000..b146c76 --- /dev/null +++ b/storage/src/main/java/com/groom/yummy/config/MySqlConfig.java @@ -0,0 +1,53 @@ +//package com.groom.yummy.config; +// +// +//import com.zaxxer.hikari.HikariDataSource; +//import jakarta.persistence.EntityManagerFactory; +//import org.springframework.beans.factory.annotation.Qualifier; +//import org.springframework.boot.context.properties.ConfigurationProperties; +//import org.springframework.boot.jdbc.DataSourceBuilder; +//import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder; +//import org.springframework.context.annotation.Bean; +//import org.springframework.context.annotation.Configuration; +//import org.springframework.context.annotation.Primary; +//import org.springframework.context.annotation.Profile; +//import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +//import org.springframework.orm.jpa.JpaTransactionManager; +//import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; +//import org.springframework.transaction.PlatformTransactionManager; +//import org.springframework.transaction.annotation.EnableTransactionManagement; +// +// +//import javax.sql.DataSource; +//import java.util.HashMap; +//import java.util.Map; +// +//@Configuration +//@Profile("!test") +//@EnableTransactionManagement +//@EnableJpaRepositories(basePackages = {"com.groom.yummy.domain"}) +//public class MySqlConfig { +// @Bean(name = "dataSource") +// @Primary +// @ConfigurationProperties(prefix = "spring.datasource") +// public DataSource dataSource(){ +// return DataSourceBuilder.create().type(HikariDataSource.class).build(); +// } +// @Primary +// @Bean(name = "entityManagerFactory") +// public LocalContainerEntityManagerFactoryBean entityManagerFactory(EntityManagerFactoryBuilder builder, @Qualifier("dataSource") DataSource dataSource) { +// +// Map properties = new HashMap(); +// properties.put("hibernate.hbm2ddl.auto", "update"); +// properties.put("hibernate.dialect", "org.hibernate.dialect.MySQL8Dialect"); +// +// return builder.dataSource(dataSource).packages("com.groom.yummy.domain").persistenceUnit("primary").properties(properties).build(); +// } +// +// @Primary +// @Bean(name = "transactionManager") +// PlatformTransactionManager transactionManager(@Qualifier("entityManagerFactory") EntityManagerFactory entityManagerFactory) { +// return new JpaTransactionManager(entityManagerFactory); +// } +//} +// diff --git a/storage/src/main/java/com/groom/yummy/domain/BaseEntity.java b/storage/src/main/java/com/groom/yummy/domain/BaseEntity.java new file mode 100644 index 0000000..d7dd69e --- /dev/null +++ b/storage/src/main/java/com/groom/yummy/domain/BaseEntity.java @@ -0,0 +1,31 @@ +package com.groom.yummy.domain; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@MappedSuperclass +@SuperBuilder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EntityListeners(AuditingEntityListener.class) +@Getter +public class BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @CreationTimestamp + private LocalDateTime createdAt; + + @UpdateTimestamp + private LocalDateTime updatedAt; + +} diff --git a/storage/src/main/java/com/groom/yummy/domain/group/GroupEntity.java b/storage/src/main/java/com/groom/yummy/domain/group/GroupEntity.java new file mode 100644 index 0000000..82be750 --- /dev/null +++ b/storage/src/main/java/com/groom/yummy/domain/group/GroupEntity.java @@ -0,0 +1,90 @@ +package com.groom.yummy.domain.group; + +import com.groom.yummy.domain.BaseEntity; +import com.groom.yummy.domain.store.StoreEntity; +import com.groom.yummy.group.Group; +import com.groom.yummy.group.MeetingStatus; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.time.LocalDateTime; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@Table(name = "teams") +@SuperBuilder +public class GroupEntity extends BaseEntity { + + @Column(nullable = false) + private String title; + + @Column(nullable = false) + private String content; + + @Column(nullable = false) + private Integer maxParticipants; + + @Column(nullable = false) + private Integer minParticipants; + + @Column(nullable = false) + private Integer currentParticipants; + + @Column(nullable = false) + private LocalDateTime meetingDate; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private MeetingStatus meetingStatus; + + private Long storeId; + + @Builder + public GroupEntity(String title, String content, Integer maxParticipants, Integer minParticipants, + Integer currentParticipants, LocalDateTime meetingDate, MeetingStatus meetingStatus, Long storeId) { + this.title = title; + this.content = content; + this.maxParticipants = maxParticipants; + this.minParticipants = minParticipants; + this.currentParticipants = currentParticipants; + this.meetingDate = meetingDate; + this.meetingStatus = meetingStatus; + this.storeId = storeId; + } + + public void updateCurrentCount(int count){ + this.currentParticipants = count; + } + + public static Group toGroupDomain(GroupEntity groupEntity) { + return Group.builder() + .id(groupEntity.getId()) + .title(groupEntity.getTitle()) + .content(groupEntity.getContent()) + .maxParticipants(groupEntity.getMaxParticipants()) + .minParticipants(groupEntity.getMinParticipants()) + .currentParticipants(groupEntity.getCurrentParticipants()) + .meetingDate(groupEntity.getMeetingDate()) + .meetingStatus(groupEntity.getMeetingStatus()) + .storeId(groupEntity.getStoreId()) + .build(); + } + + public static GroupEntity fromGroupDomain(Group group) { + return GroupEntity.builder() + .title(group.getTitle()) + .content(group.getContent()) + .maxParticipants(group.getMaxParticipants()) + .minParticipants(group.getMinParticipants()) + .currentParticipants(group.getCurrentParticipants()) + .meetingDate(group.getMeetingDate()) + .meetingStatus(group.getMeetingStatus()) + .storeId(group.getStoreId()) + .build(); + } +} diff --git a/storage/src/main/java/com/groom/yummy/domain/group/GroupEntityRepository.java b/storage/src/main/java/com/groom/yummy/domain/group/GroupEntityRepository.java new file mode 100644 index 0000000..f218b5f --- /dev/null +++ b/storage/src/main/java/com/groom/yummy/domain/group/GroupEntityRepository.java @@ -0,0 +1,58 @@ +package com.groom.yummy.domain.group; + +import com.groom.yummy.domain.store.StoreEntity; +import com.groom.yummy.domain.store.StoreJpaRepository; +import com.groom.yummy.group.Group; +import com.groom.yummy.group.GroupRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + + +@Repository +@RequiredArgsConstructor +public class GroupEntityRepository implements GroupRepository { + + private final GroupJpaRepository groupJpaRepository; + + @Override + public Long saveGroup(Group group) { +// StoreEntity storeEntity = storeJpaRepository.findById(storeId) +// .orElseThrow(() -> new IllegalArgumentException("Store not found for id: " + storeId)); + GroupEntity groupEntity = GroupEntity.fromGroupDomain(group); + return groupJpaRepository.save(groupEntity).getId(); + } + + @Override + public Optional findGroupById(Long id) { + return groupJpaRepository.findById(id).map(GroupEntity::toGroupDomain); + } + + @Override + public List findAllGroups(String category, String regionCode, String storeName, int page) { + return groupJpaRepository.findAll().stream() + .map(GroupEntity::toGroupDomain) + .collect(Collectors.toList()); + } + + @Override + public void updateGroupParticipants(Long groupId, int participants) { + GroupEntity groupEntity = groupJpaRepository.findById(groupId) + .orElseThrow(() -> new IllegalArgumentException("Group not found for id: " + groupId)); +// groupEntity = GroupEntity.builder() +// .title(groupEntity.getTitle()) +// .content(groupEntity.getContent()) +// .maxParticipants(groupEntity.getMaxParticipants()) +// .minParticipants(groupEntity.getMinParticipants()) +// .currentParticipants(participants) +// .meetingDate(groupEntity.getMeetingDate()) +// .meetingStatus(groupEntity.getMeetingStatus()) +// .storeId(groupEntity.getStoreId()) +// .build(); +// groupJpaRepository.save(groupEntity); + groupEntity.updateCurrentCount(participants); + } +} \ No newline at end of file diff --git a/storage/src/main/java/com/groom/yummy/domain/group/GroupJpaRepository.java b/storage/src/main/java/com/groom/yummy/domain/group/GroupJpaRepository.java new file mode 100644 index 0000000..f19cf3e --- /dev/null +++ b/storage/src/main/java/com/groom/yummy/domain/group/GroupJpaRepository.java @@ -0,0 +1,6 @@ +package com.groom.yummy.domain.group; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface GroupJpaRepository extends JpaRepository { +} diff --git a/storage/src/main/java/com/groom/yummy/domain/region/RegionEntity.java b/storage/src/main/java/com/groom/yummy/domain/region/RegionEntity.java new file mode 100644 index 0000000..f19cb27 --- /dev/null +++ b/storage/src/main/java/com/groom/yummy/domain/region/RegionEntity.java @@ -0,0 +1,45 @@ +package com.groom.yummy.domain.region; + +import com.groom.yummy.domain.BaseEntity; +import com.groom.yummy.region.Region; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@Table(name = "region") +@SuperBuilder +public class RegionEntity extends BaseEntity { + + @Column(nullable = false, unique = true) + private String regionName; + + @Column(nullable = false, unique = true) + private String regionCode; + + @Builder + public RegionEntity(String regionName, String regionCode) { + this.regionName = regionName; + this.regionCode = regionCode; + } + + public Region toRegionDomain() { + return Region.builder() + .id(this.getId()) + .regionName(this.regionName) + .regionCode(this.regionCode) + .build(); + } + + public static RegionEntity fromRegionDomain(Region region) { + if (region == null) { + throw new IllegalArgumentException("지역 설정은 필수입니다."); + } + return new RegionEntity(region.getRegionName(), region.getRegionCode()); + } +} diff --git a/storage/src/main/java/com/groom/yummy/domain/region/RegionEntityRepository.java b/storage/src/main/java/com/groom/yummy/domain/region/RegionEntityRepository.java new file mode 100755 index 0000000..3b27723 --- /dev/null +++ b/storage/src/main/java/com/groom/yummy/domain/region/RegionEntityRepository.java @@ -0,0 +1,35 @@ +package com.groom.yummy.domain.region; + +import com.groom.yummy.region.Region; +import com.groom.yummy.region.RegionRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@Repository +@RequiredArgsConstructor +public class RegionEntityRepository implements RegionRepository { + + private final RegionJpaRepository regionJpaRepository; + + @Override + public Optional findRegionById(Long id) { + return regionJpaRepository.findById(id).map(RegionEntity::toRegionDomain); + } + + @Override + public Long saveRegion(Region region) { + RegionEntity regionEntity = RegionEntity.fromRegionDomain(region); + return regionJpaRepository.save(regionEntity).getId(); + } + + @Override + public List findAllRegions() { + return regionJpaRepository.findAll().stream() + .map(RegionEntity::toRegionDomain) + .collect(Collectors.toList()); + } +} diff --git a/storage/src/main/java/com/groom/yummy/domain/region/RegionJpaRepository.java b/storage/src/main/java/com/groom/yummy/domain/region/RegionJpaRepository.java new file mode 100644 index 0000000..aa0a374 --- /dev/null +++ b/storage/src/main/java/com/groom/yummy/domain/region/RegionJpaRepository.java @@ -0,0 +1,6 @@ +package com.groom.yummy.domain.region; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RegionJpaRepository extends JpaRepository { +} diff --git a/storage/src/main/java/com/groom/yummy/domain/reply/ReplyEntity.java b/storage/src/main/java/com/groom/yummy/domain/reply/ReplyEntity.java new file mode 100644 index 0000000..e76456f --- /dev/null +++ b/storage/src/main/java/com/groom/yummy/domain/reply/ReplyEntity.java @@ -0,0 +1,66 @@ +package com.groom.yummy.domain.reply; + +import com.groom.yummy.domain.BaseEntity; +import com.groom.yummy.domain.group.GroupEntity; +import com.groom.yummy.domain.user.UserEntity; +import com.groom.yummy.reply.Reply; + +import jakarta.persistence.*; +import lombok.*; +import lombok.experimental.SuperBuilder; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@Table(name="reply") +@SuperBuilder +public class ReplyEntity extends BaseEntity { + + @Column(nullable = false) + private String content; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_id") + private ReplyEntity parentReply; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private UserEntity user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "group_id", nullable = false) + private GroupEntity group; + + @Builder + public ReplyEntity(String content, ReplyEntity parentReply, UserEntity user, GroupEntity group) { + this.content = content; + this.parentReply = parentReply; + this.user = user; + this.group = group; + } + + public static Reply toModel(ReplyEntity entity) { + return Reply.builder() + .id(entity.getId()) + .content(entity.getContent()) + .parentReplyId(entity.getParentReply() != null ? entity.getParentReply().getId() : null) + .userId(entity.getUser().getId()) + .groupId(entity.getGroup().getId()) + .createdAt(entity.getCreatedAt()) + .updatedAt(entity.getUpdatedAt()) + .build(); + } + + public static ReplyEntity toEntity(Reply reply, ReplyEntity parent, UserEntity user, GroupEntity group) { + return ReplyEntity.builder() + .content(reply.getContent()) + .parentReply(parent) + .user(user) + .group(group) + .build(); + } + + public void updateReply(String content) { + this.content = content; + } +} diff --git a/storage/src/main/java/com/groom/yummy/domain/reply/ReplyEntityRepository.java b/storage/src/main/java/com/groom/yummy/domain/reply/ReplyEntityRepository.java new file mode 100644 index 0000000..636009b --- /dev/null +++ b/storage/src/main/java/com/groom/yummy/domain/reply/ReplyEntityRepository.java @@ -0,0 +1,81 @@ +package com.groom.yummy.domain.reply; + +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import com.groom.yummy.domain.group.GroupEntity; +import com.groom.yummy.domain.group.GroupJpaRepository; +import com.groom.yummy.domain.user.UserEntity; +import com.groom.yummy.domain.user.UserJpaRepository; +import com.groom.yummy.reply.Reply; +import com.groom.yummy.reply.ReplyRepository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class ReplyEntityRepository implements ReplyRepository { + + private final ReplyJpaRepository replyRepository; + private final UserJpaRepository userRepository; + private final GroupJpaRepository groupJpaRepository; + + @Override + public Optional findById(Long id) { + return replyRepository.findById(id).map(ReplyEntity::toModel); + } + + @Override + public Page findAllByParentId(Long parentId, Pageable pageable) { + return replyRepository.findByParentReplyId(parentId, pageable).map(ReplyEntity::toModel); + } + + @Override + public Optional save(Reply reply) { + ReplyEntity parentReply = findParentReply(reply.getParentReplyId()); + UserEntity user = findUser(reply.getUserId()); + GroupEntity group = findGroup(reply.getGroupId()); + + ReplyEntity replyEntity = ReplyEntity.toEntity(reply, parentReply, user, group); + return Optional.ofNullable(ReplyEntity.toModel(replyRepository.save(replyEntity))); + } + + @Override + public Page findByGroupId(Long groupId, Pageable pageable) { + return replyRepository.findByGroupId(groupId, pageable).map(ReplyEntity::toModel); + } + + @Override + public void deleteById(Long id) { + // 자식 댓글 존재 여부 확인 + if (replyRepository.existsByParentReplyId(id)) { + throw new IllegalStateException("자식 댓글이 존재하여 삭제할 수 없습니다. ID: " + id); + } + + replyRepository.deleteById(id); + } + + // 부모 댓글 조회 + private ReplyEntity findParentReply(Long parentReplyId) { + if (parentReplyId == null) { + return null; // 부모 댓글이 없으면 null 반환 + } + return replyRepository.findById(parentReplyId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 부모 댓글")); + } + + // 사용자 조회 + private UserEntity findUser(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원")); + } + + // 그룹 조회 + private GroupEntity findGroup(Long groupId) { + return groupJpaRepository.findById(groupId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 그룹")); + } +} diff --git a/storage/src/main/java/com/groom/yummy/domain/reply/ReplyJpaRepository.java b/storage/src/main/java/com/groom/yummy/domain/reply/ReplyJpaRepository.java new file mode 100644 index 0000000..5108055 --- /dev/null +++ b/storage/src/main/java/com/groom/yummy/domain/reply/ReplyJpaRepository.java @@ -0,0 +1,15 @@ +package com.groom.yummy.domain.reply; + +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReplyJpaRepository extends JpaRepository { + Page findByGroupId(Long groupId, Pageable pageable); + + Page findByParentReplyId(Long parentId, Pageable pageable); + + boolean existsByParentReplyId(Long id); +} diff --git a/storage/src/main/java/com/groom/yummy/domain/store/StoreEntity.java b/storage/src/main/java/com/groom/yummy/domain/store/StoreEntity.java new file mode 100644 index 0000000..ab6fc81 --- /dev/null +++ b/storage/src/main/java/com/groom/yummy/domain/store/StoreEntity.java @@ -0,0 +1,67 @@ +package com.groom.yummy.domain.store; + +import com.groom.yummy.domain.BaseEntity; +import com.groom.yummy.domain.region.RegionEntity; +import com.groom.yummy.store.Category_; +import com.groom.yummy.store.Store; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@Table(name = "store") +@SuperBuilder +public class StoreEntity extends BaseEntity { + + @Column(nullable = false) + private String name; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Category_ category; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "region_id", nullable = false) + private RegionEntity region; + + private StoreEntity(String name, Category_ category, RegionEntity region) { + this.name = name; + this.category = category; + this.region = region; + } + + public static Store toStoreDomain(StoreEntity storeEntity) { + return Store.builder() + .storeId(storeEntity.getId()) + .name(storeEntity.getName()) + .regionId(storeEntity.getRegion().getId()) + .category(storeEntity.getCategory()) + .build(); + } + + public static StoreEntity fromStoreDomain(Store store, RegionEntity region) { + return StoreEntity.builder() + .name(store.getName()) + .category(store.getCategory()) + .region(region) + .build(); + } + + public static StoreEntity create(String name, Category_ category, RegionEntity region) { + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("Store name must not be null or blank"); + } + if (category == null) { + throw new IllegalArgumentException("Category must not be null"); + } + if (region == null) { + throw new IllegalArgumentException("Region must not be null"); + } + return new StoreEntity(name, category, region); + } + +} diff --git a/storage/src/main/java/com/groom/yummy/domain/store/StoreEntityRepository.java b/storage/src/main/java/com/groom/yummy/domain/store/StoreEntityRepository.java new file mode 100755 index 0000000..85f3f2a --- /dev/null +++ b/storage/src/main/java/com/groom/yummy/domain/store/StoreEntityRepository.java @@ -0,0 +1,46 @@ +package com.groom.yummy.domain.store; + +import com.groom.yummy.domain.region.RegionEntity; +import com.groom.yummy.domain.region.RegionJpaRepository; +import com.groom.yummy.store.Store; +import com.groom.yummy.store.StoreRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@Repository +@RequiredArgsConstructor +public class StoreEntityRepository implements StoreRepository { + + private final StoreJpaRepository storeJpaRepository; + private final RegionJpaRepository regionJpaRepository; + + @Override + public Optional findStoreById(Long id) { + return storeJpaRepository.findById(id).map(StoreEntity::toStoreDomain); + } + + @Override + public Long saveStore(Store store, Long regionId) { + RegionEntity regionEntity = regionJpaRepository.findById(regionId) + .orElseThrow(() -> new IllegalArgumentException("지역을 찾을 수 없습니다.")); + + StoreEntity storeEntity = StoreEntity.fromStoreDomain(store, regionEntity); + return storeJpaRepository.save(storeEntity).getId(); + } + + @Override + public List findAllStores() { + return storeJpaRepository.findAll().stream() + .map(StoreEntity::toStoreDomain) + .collect(Collectors.toList()); + } + + @Override + public boolean existsByNameAndRegionId(String name, Long regionId) { + return storeJpaRepository.existsByNameAndRegion_Id(name, regionId); + } +} diff --git a/storage/src/main/java/com/groom/yummy/domain/store/StoreJpaRepository.java b/storage/src/main/java/com/groom/yummy/domain/store/StoreJpaRepository.java new file mode 100644 index 0000000..9047aea --- /dev/null +++ b/storage/src/main/java/com/groom/yummy/domain/store/StoreJpaRepository.java @@ -0,0 +1,7 @@ +package com.groom.yummy.domain.store; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface StoreJpaRepository extends JpaRepository { + boolean existsByNameAndRegion_Id(String name, Long regionId); +} diff --git a/storage/src/main/java/com/groom/yummy/domain/user/UserEntity.java b/storage/src/main/java/com/groom/yummy/domain/user/UserEntity.java new file mode 100644 index 0000000..e12948b --- /dev/null +++ b/storage/src/main/java/com/groom/yummy/domain/user/UserEntity.java @@ -0,0 +1,69 @@ +package com.groom.yummy.domain.user; + +import com.groom.yummy.domain.BaseEntity; +import com.groom.yummy.user.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@Table(name = "users") +@SuperBuilder +public class UserEntity extends BaseEntity { + + @Column(nullable = false, unique = true) + private String email; + + @Column(nullable = false) + private String nickname; + + @Column(nullable = false) + private Long groupJoinCount; + + @Column(nullable = false) + private Long groupAttendanceCount; + + @Column(nullable = false) + private boolean isDeleted; + + @Column(nullable = false) + private String role; + + public void updateNickname(String nickname){ + this.nickname = nickname; + } + + public void deleteUser(boolean isDeleted){ + this.isDeleted = isDeleted; + } + + public static User toModel(UserEntity userEntity){ + return User.builder() + .id(userEntity.getId()) + .nickname(userEntity.nickname) + .email(userEntity.getEmail()) + .role(userEntity.role) + .groupAttendanceCount(userEntity.groupAttendanceCount) + .groupJoinCount(userEntity.groupJoinCount) + .isDeleted(userEntity.isDeleted) + .build(); + } + + public static UserEntity toEntity(User user){ + return UserEntity.builder() + .nickname(user.getNickname()) + .email(user.getEmail()) + .role(user.getRole()) + .groupAttendanceCount(user.getGroupAttendanceCount()) + .groupJoinCount(user.getGroupJoinCount()) + .isDeleted(user.isDeleted()) + .build(); + } +} diff --git a/storage/src/main/java/com/groom/yummy/domain/user/UserEntityRepository.java b/storage/src/main/java/com/groom/yummy/domain/user/UserEntityRepository.java new file mode 100644 index 0000000..81ffa3b --- /dev/null +++ b/storage/src/main/java/com/groom/yummy/domain/user/UserEntityRepository.java @@ -0,0 +1,30 @@ +package com.groom.yummy.domain.user; + +import com.groom.yummy.user.User; +import com.groom.yummy.user.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class UserEntityRepository implements UserRepository { + private final UserJpaRepository userJpaRepository; + + @Override + public Optional findById(Long userId) { + return userJpaRepository.findById(userId).map(UserEntity::toModel); + } + + @Override + public Optional findByEmail(String email) { + return userJpaRepository.findByEmail(email).map(UserEntity::toModel); + } + + @Override + public User save(User user) { + UserEntity userEntity = userJpaRepository.save(UserEntity.toEntity(user)); + return UserEntity.toModel(userEntity); + } +} diff --git a/storage/src/main/java/com/groom/yummy/domain/user/UserJpaRepository.java b/storage/src/main/java/com/groom/yummy/domain/user/UserJpaRepository.java new file mode 100644 index 0000000..9468d0d --- /dev/null +++ b/storage/src/main/java/com/groom/yummy/domain/user/UserJpaRepository.java @@ -0,0 +1,9 @@ +package com.groom.yummy.domain.user; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserJpaRepository extends JpaRepository { + Optional findByEmail(String email); +} diff --git a/storage/src/main/java/com/groom/yummy/domain/usertogroup/UserToGroupEntity.java b/storage/src/main/java/com/groom/yummy/domain/usertogroup/UserToGroupEntity.java new file mode 100644 index 0000000..10b821c --- /dev/null +++ b/storage/src/main/java/com/groom/yummy/domain/usertogroup/UserToGroupEntity.java @@ -0,0 +1,55 @@ +package com.groom.yummy.domain.usertogroup; + +import com.groom.yummy.domain.BaseEntity; +import com.groom.yummy.domain.group.GroupEntity; +import com.groom.yummy.domain.user.UserEntity; +import com.groom.yummy.usertogroup.AttendanceStatus; +import jakarta.persistence.*; +import lombok.*; +import lombok.experimental.SuperBuilder; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "user_groups") +@SuperBuilder +@Getter +public class UserToGroupEntity extends BaseEntity { + +// @Id +// @GeneratedValue(strategy = GenerationType.IDENTITY) +// private Long id; + + @Column(nullable = false) + private boolean isLeader; + + @Getter + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private AttendanceStatus attendanceStatus; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private UserEntity user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "group_id", nullable = false) + private GroupEntity group; + + @Builder + public UserToGroupEntity(AttendanceStatus attendanceStatus, boolean isLeader, UserEntity user, GroupEntity group) { + this.attendanceStatus = attendanceStatus; + this.isLeader = isLeader; + this.user = user; + this.group = group; + } + + public static UserToGroupEntity create(GroupEntity groupEntity, UserEntity userEntity, boolean isLeader) { + return UserToGroupEntity.builder() + .group(groupEntity) + .user(userEntity) + .isLeader(isLeader) + .attendanceStatus(AttendanceStatus.APPROVED) + .build(); + } + +} diff --git a/storage/src/main/java/com/groom/yummy/domain/usertogroup/UserToGroupEntityRepository.java b/storage/src/main/java/com/groom/yummy/domain/usertogroup/UserToGroupEntityRepository.java new file mode 100644 index 0000000..14f5f3d --- /dev/null +++ b/storage/src/main/java/com/groom/yummy/domain/usertogroup/UserToGroupEntityRepository.java @@ -0,0 +1,64 @@ +package com.groom.yummy.domain.usertogroup; + +import com.groom.yummy.domain.group.GroupEntity; +import com.groom.yummy.domain.group.GroupJpaRepository; +import com.groom.yummy.domain.store.StoreEntity; +import com.groom.yummy.domain.store.StoreJpaRepository; +import com.groom.yummy.domain.user.UserEntity; +import com.groom.yummy.domain.user.UserJpaRepository; +import com.groom.yummy.group.Group; +import com.groom.yummy.store.Store; +import com.groom.yummy.user.User; +import com.groom.yummy.user.UserRepository; +import com.groom.yummy.usertogroup.AttendanceStatus; +import com.groom.yummy.usertogroup.UserToGroup; +import com.groom.yummy.usertogroup.UserToGroupRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class UserToGroupEntityRepository implements UserToGroupRepository { + + private final UserToGroupJpaRepository userToGroupJpaRepository; + private final GroupJpaRepository groupJpaRepository; + private final UserJpaRepository userJpaRepository; + + @Override + public void saveUserToGroup(Long groupId, Long userId, boolean isLeader, AttendanceStatus attendanceStatus) { +// StoreEntity storeEntity = group.getStoreId() != null +// ? storeJpaRepository.findById(group.getStoreId()) +// .orElseThrow(() -> new IllegalArgumentException("Store not found for id: " + group.getStoreId())) +// : null; + + GroupEntity groupEntity = groupJpaRepository.findById(groupId).orElseThrow(); + UserEntity userEntity = userJpaRepository.findById(userId).orElseThrow(); + + UserToGroupEntity userToGroupEntity = UserToGroupEntity.builder() + .group(groupEntity) + .user(userEntity) + .isLeader(isLeader) + .attendanceStatus(attendanceStatus) + .build(); + + userToGroupJpaRepository.save(userToGroupEntity); + } + + @Override + public Optional findByGroupAndUser(Group group, User user) { + GroupEntity groupEntity = GroupEntity.fromGroupDomain(group); +// UserEntity userEntity = UserEntity.toEntity(user); + + return userToGroupJpaRepository.findById(groupEntity.getId()) + .map(entity -> UserToGroup.builder() + .id(entity.getId()) + .group(group) + .user(user) + .isLeader(entity.isLeader()) + .attendanceStatus(entity.getAttendanceStatus()) + .build()); + } +} + diff --git a/storage/src/main/java/com/groom/yummy/domain/usertogroup/UserToGroupJpaRepository.java b/storage/src/main/java/com/groom/yummy/domain/usertogroup/UserToGroupJpaRepository.java new file mode 100644 index 0000000..1622ddf --- /dev/null +++ b/storage/src/main/java/com/groom/yummy/domain/usertogroup/UserToGroupJpaRepository.java @@ -0,0 +1,11 @@ +package com.groom.yummy.domain.usertogroup; + +import com.groom.yummy.domain.group.GroupEntity; +import com.groom.yummy.domain.user.UserEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserToGroupJpaRepository extends JpaRepository { + Optional findByGroupAndUser(GroupEntity group, UserEntity user); +} diff --git a/storage/src/main/java/com/groom/yummy/handler/ReplyEventListener.java b/storage/src/main/java/com/groom/yummy/handler/ReplyEventListener.java new file mode 100644 index 0000000..0afd178 --- /dev/null +++ b/storage/src/main/java/com/groom/yummy/handler/ReplyEventListener.java @@ -0,0 +1,28 @@ +package com.groom.yummy.handler; + +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +import com.groom.yummy.domain.reply.ReplyEntity; +import com.groom.yummy.domain.reply.ReplyJpaRepository; +import com.groom.yummy.reply.event.ReplyUpdatedEvent; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Component +@RequiredArgsConstructor +@Slf4j +public class ReplyEventListener { + + private final ReplyJpaRepository replyJpaRepository; + + @EventListener + public void handleReplyUpdatedEvent(ReplyUpdatedEvent event) { + ReplyEntity replyEntity = replyJpaRepository.findById(event.replyId()) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 댓글의 아이디")); + + replyEntity.updateReply(event.content()); + log.info("댓글이 수정되었습니다. ID: {}, 내용: {}", event.replyId(), event.content()); + } +} \ No newline at end of file diff --git a/storage/src/main/java/com/groom/yummy/handler/UserEventHandler.java b/storage/src/main/java/com/groom/yummy/handler/UserEventHandler.java new file mode 100644 index 0000000..c680305 --- /dev/null +++ b/storage/src/main/java/com/groom/yummy/handler/UserEventHandler.java @@ -0,0 +1,32 @@ +package com.groom.yummy.handler; + +import com.groom.yummy.domain.user.UserEntity; +import com.groom.yummy.domain.user.UserJpaRepository; +import com.groom.yummy.user.event.UserDeleteEvent; +import com.groom.yummy.user.event.UserNicknameChangedEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@RequiredArgsConstructor +@Slf4j +@Component +public class UserEventHandler { + private final UserJpaRepository userJpaRepository; + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handlerNicknameChange(UserNicknameChangedEvent event){ + UserEntity userEntity = userJpaRepository.findById(event.userId()).orElseThrow(); + userEntity.updateNickname(event.newNickname()); + log.info("userEntity nickname : {}", userEntity.getNickname()); + } + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handleUserDelete(UserDeleteEvent event) { + UserEntity userEntity = userJpaRepository.findById(event.userId()).orElseThrow(); + userEntity.deleteUser(event.isDeleted()); + log.info("userEntity isDelete : {}", userEntity.isDeleted()); + } +} diff --git a/storage/src/test/java/com/groom/yummy/TestApplication.java b/storage/src/test/java/com/groom/yummy/TestApplication.java new file mode 100644 index 0000000..e7029c8 --- /dev/null +++ b/storage/src/test/java/com/groom/yummy/TestApplication.java @@ -0,0 +1,18 @@ +package com.groom.yummy; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +// 테스트 환경에서 mock 테스트가 아닌 실제 db(H2 db)에 접근하여 테스트 하기 위해 추가. +@SpringBootApplication +public class TestApplication { + public static void main(String[] args) { + SpringApplication.run(TestApplication.class, args); + } +} + +/* 컴포넌트 스캔의 범위 + 예를 들어, TestApplication 클래스가 src/test/java/com.groom.yummy 패키지에 위치하면: + 스캔 범위: com.groom.yummy와 그 하위 패키지. + 따라서 main 폴더 아래의 com.groom.yummy에 위치한 클래스들도 빈으로 등록됩니다. + */ diff --git a/storage/src/test/java/com/groom/yummy/domain/user/UserEntityRepositoryTest.java b/storage/src/test/java/com/groom/yummy/domain/user/UserEntityRepositoryTest.java new file mode 100644 index 0000000..f319db3 --- /dev/null +++ b/storage/src/test/java/com/groom/yummy/domain/user/UserEntityRepositoryTest.java @@ -0,0 +1,92 @@ +package com.groom.yummy.domain.user; + +import com.groom.yummy.user.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class UserEntityRepositoryTest { + + @Autowired + private UserEntityRepository userEntityRepository; + + @Autowired + private UserJpaRepository userJpaRepository; + + private UserEntity testUserEntity; + + @BeforeEach + void setUp(){ + testUserEntity = UserEntity.builder() + .email("email@gmail.com") + .nickname("강형준") + .role("ROLE_USER") + .groupAttendanceCount(0L) + .groupJoinCount(0L) + .isDeleted(false) + .build(); + + userJpaRepository.save(testUserEntity); + } + + @Test + void findByIdTest() { + // given + Long userId = testUserEntity.getId(); + // when + Optional result = userEntityRepository.findById(userId); + // then + assertTrue(result.isPresent()); + assertEquals(testUserEntity.getId(), result.get().getId()); + assertEquals(testUserEntity.getEmail(), result.get().getEmail()); + assertEquals(testUserEntity.getNickname(), result.get().getNickname()); + assertEquals(testUserEntity.getRole(), result.get().getRole()); + assertEquals(testUserEntity.isDeleted(), result.get().isDeleted()); + } + + @Test + void findByEmailTest() { + // given + String email = "email@gmail.com"; + + // when + Optional result = userEntityRepository.findByEmail(email); + + // then + assertTrue(result.isPresent()); + assertEquals(testUserEntity.getId(),result.get().getId()); + assertEquals(testUserEntity.getEmail(), result.get().getEmail()); + assertEquals(testUserEntity.getNickname(), result.get().getNickname()); + assertEquals(testUserEntity.getRole(), result.get().getRole()); + } + + @Test + void saveTest() { + // given + String email = "email@naver.com"; + String nickname = "홍길동"; + String role = "ROLE_USER"; + boolean isDeleted = false; + User user = User.builder().email(email).nickname(nickname).role(role).groupJoinCount(0L).groupAttendanceCount(0L).isDeleted(isDeleted).build(); + + // when + User result = userEntityRepository.save(user); + + // then + assertEquals(result.getEmail(), email); + assertEquals(result.getNickname(), nickname); + assertEquals(result.getRole(), role); + assertEquals(result.isDeleted(), isDeleted); + } +} \ No newline at end of file diff --git a/storage/src/test/java/com/groom/yummy/handler/UserEventHandlerTest.java b/storage/src/test/java/com/groom/yummy/handler/UserEventHandlerTest.java new file mode 100644 index 0000000..9d37935 --- /dev/null +++ b/storage/src/test/java/com/groom/yummy/handler/UserEventHandlerTest.java @@ -0,0 +1,87 @@ +package com.groom.yummy.handler; + +import com.groom.yummy.domain.user.UserEntity; +import com.groom.yummy.domain.user.UserJpaRepository; +import com.groom.yummy.user.event.UserDeleteEvent; +import com.groom.yummy.user.event.UserNicknameChangedEvent; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.annotation.Rollback; +import org.springframework.test.context.transaction.TestTransaction; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@Transactional +class UserEventHandlerTest { + + @Autowired + private ApplicationEventPublisher eventPublisher; + + @Autowired + private UserJpaRepository userJpaRepository; + + private UserEntity testUserEntity; + + @BeforeEach + void setUp(){ + testUserEntity = UserEntity.builder() + .email("email@gmail.com") + .nickname("강형준") + .role("ROLE_USER") + .groupAttendanceCount(0L) + .groupJoinCount(0L) + .isDeleted(false) + .build(); + userJpaRepository.save(testUserEntity); + } + + @Test + @DirtiesContext //테스트 실행 후에 Spring 애플리케이션 컨텍스트를 재생성, 트랜잭션 더러워져서 발생한 문제 일단 해결 + void handlerNicknameChaneTest(){ + // given + Long userId = testUserEntity.getId(); + String newNickname = "새로운강형준"; + + // when + eventPublisher.publishEvent(new UserNicknameChangedEvent(userId, newNickname)); + + // 해당 로직은 커밋 이전에 반영되모르 명시적으로 커밋을 설정한다. + TestTransaction.flagForCommit(); + TestTransaction.end(); + + // then + Optional updatedUser = userJpaRepository.findById(userId); + assertTrue(updatedUser.isPresent()); + assertEquals(newNickname, updatedUser.get().getNickname()); + } + + @Test + @DirtiesContext //테스트 실행 후에 Spring 애플리케이션 컨텍스트를 재생성, 트랜잭션 더러워져서 발생한 문제 일단 해결 + void handleUserDeleteTest(){ + + // given + Long userId = testUserEntity.getId(); + boolean isDeleted = true; + + // when + eventPublisher.publishEvent(new UserDeleteEvent(userId, isDeleted)); + + // 해당 로직은 커밋 이전에 반영되모르 명시적으로 커밋을 설정한다. + TestTransaction.flagForCommit(); + TestTransaction.end(); + + // then + Optional deletedUser = userJpaRepository.findById(userId); + assertTrue(deletedUser.isPresent()); + assertTrue(deletedUser.get().isDeleted()); + } +} \ No newline at end of file