diff --git a/src/main/java/net/teumteum/core/security/SecurityConfig.java b/src/main/java/net/teumteum/core/security/SecurityConfig.java index 17d77fe6..d9032047 100644 --- a/src/main/java/net/teumteum/core/security/SecurityConfig.java +++ b/src/main/java/net/teumteum/core/security/SecurityConfig.java @@ -49,7 +49,7 @@ public WebSecurityCustomizer webSecurityCustomizer() { SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http.csrf(AbstractHttpConfigurer::disable).cors(cors -> cors.configurationSource(corsConfigurationSource())) .authorizeHttpRequests(request -> request.requestMatchers(PATTERNS).permitAll() - .requestMatchers(HttpMethod.POST, "/users/registers").permitAll() + .requestMatchers(HttpMethod.POST, "/users").permitAll() .anyRequest().authenticated()).httpBasic(AbstractHttpConfigurer::disable) .formLogin(AbstractHttpConfigurer::disable) .sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(STATELESS)) @@ -64,7 +64,7 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { @Bean CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); - config.addAllowedOrigin("http://localhost:3000"); + config.addAllowedOrigin("*"); config.addAllowedHeader("*"); config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); config.addExposedHeader("Authorization"); diff --git a/src/main/java/net/teumteum/meeting/domain/Meeting.java b/src/main/java/net/teumteum/meeting/domain/Meeting.java index 847c6725..59dc9135 100644 --- a/src/main/java/net/teumteum/meeting/domain/Meeting.java +++ b/src/main/java/net/teumteum/meeting/domain/Meeting.java @@ -1,6 +1,21 @@ package net.teumteum.meeting.domain; -import jakarta.persistence.*; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.Set; +import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -8,16 +23,11 @@ import net.teumteum.core.entity.TimeBaseEntity; import org.springframework.util.Assert; -import java.time.LocalDateTime; -import java.util.HashSet; -import java.util.LinkedHashSet; -import java.util.Set; - -@Entity @Getter @Builder -@NoArgsConstructor @AllArgsConstructor +@Entity(name = "meeting") +@NoArgsConstructor(access = AccessLevel.PROTECTED) public class Meeting extends TimeBaseEntity { @Id diff --git a/src/main/java/net/teumteum/teum_teum/service/TeumTeumService.java b/src/main/java/net/teumteum/teum_teum/service/TeumTeumService.java index 9dd1e21e..1963cd1c 100644 --- a/src/main/java/net/teumteum/teum_teum/service/TeumTeumService.java +++ b/src/main/java/net/teumteum/teum_teum/service/TeumTeumService.java @@ -73,11 +73,13 @@ private UserAroundLocationsResponse getUserAroundLocationsResponse(GeoResults> geoResult : Objects.requireNonNull(geoResults)) { - String userSavedTime = String.valueOf(geoResult.getContent().getName()).split(":")[1]; + String userSavedTime = String.valueOf(geoResult.getContent().getName()).split(":")[5]; long timestamp = Long.parseLong(userSavedTime); if (currentTime - timestamp < LOCATION_EXPIRATION.toMillis()) { - String userDataJson = String.valueOf(geoResult.getContent().getName()).split(":")[0]; + String savedUserLocation = String.valueOf(geoResult.getContent().getName()); + String userDataJson = savedUserLocation.substring(savedUserLocation.lastIndexOf(":") + 1); + UserData userData = null; try { userData = objectMapper.readValue(userDataJson, UserData.class); diff --git a/src/main/java/net/teumteum/user/controller/UserController.java b/src/main/java/net/teumteum/user/controller/UserController.java index 3b14dfd1..8d51bbf2 100644 --- a/src/main/java/net/teumteum/user/controller/UserController.java +++ b/src/main/java/net/teumteum/user/controller/UserController.java @@ -9,6 +9,7 @@ import net.teumteum.core.security.service.SecurityService; import net.teumteum.user.domain.request.UserRegisterRequest; import net.teumteum.user.domain.request.UserUpdateRequest; +import net.teumteum.user.domain.request.UserWithdrawRequest; import net.teumteum.user.domain.response.FriendsResponse; import net.teumteum.user.domain.response.InterestQuestionResponse; import net.teumteum.user.domain.response.UserGetResponse; @@ -20,7 +21,6 @@ import org.springframework.validation.BindingResult; import org.springframework.validation.ObjectError; import org.springframework.web.bind.MethodArgumentNotValidException; -import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -87,10 +87,10 @@ public InterestQuestionResponse getInterestQuestion(@RequestParam("user-id") Lis return userService.getInterestQuestionByUserIds(userIds, balance); } - @DeleteMapping + @PostMapping("/withdraw") @ResponseStatus(HttpStatus.OK) - public void withdraw() { - userService.withdraw(getCurrentUserId()); + public void withdraw(@Valid @RequestBody UserWithdrawRequest request) { + userService.withdraw(request, getCurrentUserId()); } @PostMapping diff --git a/src/main/java/net/teumteum/user/domain/User.java b/src/main/java/net/teumteum/user/domain/User.java index 5943d2fb..d1b87d6b 100644 --- a/src/main/java/net/teumteum/user/domain/User.java +++ b/src/main/java/net/teumteum/user/domain/User.java @@ -15,6 +15,7 @@ import java.util.HashSet; import java.util.List; import java.util.Set; +import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @@ -24,9 +25,9 @@ import org.springframework.util.Assert; @Getter -@Entity(name = "users") -@NoArgsConstructor @AllArgsConstructor +@Entity(name = "users") +@NoArgsConstructor(access = AccessLevel.PROTECTED) public class User extends TimeBaseEntity { @Id diff --git a/src/main/java/net/teumteum/user/domain/WithdrawReason.java b/src/main/java/net/teumteum/user/domain/WithdrawReason.java new file mode 100644 index 00000000..1f92e102 --- /dev/null +++ b/src/main/java/net/teumteum/user/domain/WithdrawReason.java @@ -0,0 +1,26 @@ +package net.teumteum.user.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import net.teumteum.core.entity.TimeBaseEntity; + +@Getter +@AllArgsConstructor +@Entity(name = "withdraw_reasons") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class WithdrawReason extends TimeBaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "withdraw_reason", nullable = false) + private String reason; +} diff --git a/src/main/java/net/teumteum/user/domain/WithdrawReasonRepository.java b/src/main/java/net/teumteum/user/domain/WithdrawReasonRepository.java new file mode 100644 index 00000000..4ad51e66 --- /dev/null +++ b/src/main/java/net/teumteum/user/domain/WithdrawReasonRepository.java @@ -0,0 +1,7 @@ +package net.teumteum.user.domain; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface WithdrawReasonRepository extends JpaRepository { + +} diff --git a/src/main/java/net/teumteum/user/domain/request/UserWithdrawRequest.java b/src/main/java/net/teumteum/user/domain/request/UserWithdrawRequest.java new file mode 100644 index 00000000..04cab06b --- /dev/null +++ b/src/main/java/net/teumteum/user/domain/request/UserWithdrawRequest.java @@ -0,0 +1,22 @@ +package net.teumteum.user.domain.request; + + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import jakarta.validation.constraints.Size; +import java.util.List; +import net.teumteum.user.domain.WithdrawReason; + +@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY) +public record UserWithdrawRequest( + @Size(min = 1, max = 3, message = "탈퇴 사유는 최소 1개, 최대 3개의 입력값입니다.") + List withdrawReasons +) { + + private static final Long IGNORE_ID = null; + + public List toEntity() { + return withdrawReasons.stream() + .map(withdrawReason -> new WithdrawReason(IGNORE_ID, withdrawReason)) + .toList(); + } +} diff --git a/src/main/java/net/teumteum/user/service/UserService.java b/src/main/java/net/teumteum/user/service/UserService.java index 808318bb..df469dec 100644 --- a/src/main/java/net/teumteum/user/service/UserService.java +++ b/src/main/java/net/teumteum/user/service/UserService.java @@ -10,8 +10,10 @@ import net.teumteum.user.domain.InterestQuestion; import net.teumteum.user.domain.User; import net.teumteum.user.domain.UserRepository; +import net.teumteum.user.domain.WithdrawReasonRepository; import net.teumteum.user.domain.request.UserRegisterRequest; import net.teumteum.user.domain.request.UserUpdateRequest; +import net.teumteum.user.domain.request.UserWithdrawRequest; import net.teumteum.user.domain.response.FriendsResponse; import net.teumteum.user.domain.response.InterestQuestionResponse; import net.teumteum.user.domain.response.UserGetResponse; @@ -28,6 +30,7 @@ public class UserService { private final UserRepository userRepository; + private final WithdrawReasonRepository withdrawReasonRepository; private final InterestQuestion interestQuestion; private final RedisService redisService; private final JwtService jwtService; @@ -71,11 +74,13 @@ public void addFriends(Long myId, Long friendId) { } @Transactional - public void withdraw(Long userId) { + public void withdraw(UserWithdrawRequest request, Long userId) { var existUser = getUser(userId); userRepository.delete(existUser); redisService.deleteData(String.valueOf(userId)); + + withdrawReasonRepository.saveAll(request.toEntity()); } @Transactional diff --git a/src/main/resources/db/migration/V7__create_withdraw_reason.sql b/src/main/resources/db/migration/V7__create_withdraw_reason.sql new file mode 100644 index 00000000..f2690253 --- /dev/null +++ b/src/main/resources/db/migration/V7__create_withdraw_reason.sql @@ -0,0 +1,8 @@ +create table if not exists withdraw_reason +( + id bigint not null auto_increment, + withdraw_reason varchar(30) not null, + created_at timestamp(6) not null, + updated_at timestamp(6) not null, + primary key (id) +); diff --git a/src/main/resources/db/migration/V8__rename_withdraw_reason.sql b/src/main/resources/db/migration/V8__rename_withdraw_reason.sql new file mode 100644 index 00000000..a21ba04c --- /dev/null +++ b/src/main/resources/db/migration/V8__rename_withdraw_reason.sql @@ -0,0 +1,10 @@ +drop table withdraw_reason; + +create table if not exists withdraw_reasons +( + id bigint not null auto_increment, + withdraw_reason varchar(30) not null, + created_at timestamp(6) not null, + updated_at timestamp(6) not null, + primary key (id) +); diff --git a/src/test/java/net/teumteum/integration/Api.java b/src/test/java/net/teumteum/integration/Api.java index 05d2945b..2b4c498e 100644 --- a/src/test/java/net/teumteum/integration/Api.java +++ b/src/test/java/net/teumteum/integration/Api.java @@ -6,6 +6,7 @@ import net.teumteum.teum_teum.domain.request.UserLocationRequest; import net.teumteum.user.domain.request.UserRegisterRequest; import net.teumteum.user.domain.request.UserUpdateRequest; +import net.teumteum.user.domain.request.UserWithdrawRequest; import org.springframework.boot.test.context.TestComponent; import org.springframework.context.ApplicationContext; import org.springframework.data.domain.Pageable; @@ -137,10 +138,12 @@ ResponseSpec reissueJwt(String accessToken, String refreshToken) { .exchange(); } - ResponseSpec withdrawUser(String accessToken) { - return webTestClient.delete() - .uri("/users") + ResponseSpec withdrawUser(String accessToken, UserWithdrawRequest request) { + return webTestClient + .post() + .uri("/users/withdraw") .header(HttpHeaders.AUTHORIZATION, accessToken) + .bodyValue(request) .exchange(); } diff --git a/src/test/java/net/teumteum/integration/RequestFixture.java b/src/test/java/net/teumteum/integration/RequestFixture.java index 28a35222..5f8b1dc0 100644 --- a/src/test/java/net/teumteum/integration/RequestFixture.java +++ b/src/test/java/net/teumteum/integration/RequestFixture.java @@ -1,5 +1,6 @@ package net.teumteum.integration; +import java.util.List; import java.util.UUID; import net.teumteum.core.security.Authenticated; import net.teumteum.user.domain.User; @@ -8,9 +9,14 @@ import net.teumteum.user.domain.request.UserRegisterRequest.Terms; import net.teumteum.user.domain.request.UserUpdateRequest; import net.teumteum.user.domain.request.UserUpdateRequest.NewJob; +import net.teumteum.user.domain.request.UserWithdrawRequest; public class RequestFixture { + public static UserWithdrawRequest userWithdrawRequest(List withdrawReasons) { + return new UserWithdrawRequest(withdrawReasons); + } + public static UserUpdateRequest userUpdateRequest(User user) { return new UserUpdateRequest(user.getId(), "new_name", user.getBirth(), user.getCharacterId(), user.getActivityArea(), user.getMbti(), user.getStatus().name(), user.getGoal(), newJob(user), diff --git a/src/test/java/net/teumteum/integration/UserIntegrationTest.java b/src/test/java/net/teumteum/integration/UserIntegrationTest.java index 33c69d8d..5ba74373 100644 --- a/src/test/java/net/teumteum/integration/UserIntegrationTest.java +++ b/src/test/java/net/teumteum/integration/UserIntegrationTest.java @@ -240,9 +240,10 @@ void Withdraw_user_info_api() { loginContext.setUserId(me.getId()); - // when & then + var request = RequestFixture.userWithdrawRequest(List.of("쓰지 않는 앱이에요", "오류가 생겨서 쓸 수 없어요")); - assertThatCode(() -> api.withdrawUser(VALID_TOKEN)) + // when & then + assertThatCode(() -> api.withdrawUser(VALID_TOKEN, request)) .doesNotThrowAnyException(); } @@ -252,8 +253,10 @@ void Return_500_error_if_user_not_exist() { // given repository.clearUserRepository(); + var request = RequestFixture.userWithdrawRequest(List.of("쓰지 않는 앱이에요", "오류가 생겨서 쓸 수 없어요")); + // when - var result = api.withdrawUser(VALID_TOKEN); + var result = api.withdrawUser(VALID_TOKEN, request); // then Assertions.assertThat(result.expectStatus().is5xxServerError() diff --git a/src/test/java/net/teumteum/unit/user/controller/UserControllerTest.java b/src/test/java/net/teumteum/unit/user/controller/UserControllerTest.java index 1b7d893e..61c5ad2d 100644 --- a/src/test/java/net/teumteum/unit/user/controller/UserControllerTest.java +++ b/src/test/java/net/teumteum/unit/user/controller/UserControllerTest.java @@ -12,6 +12,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; import net.teumteum.core.security.SecurityConfig; import net.teumteum.core.security.filter.JwtAuthenticationFilter; import net.teumteum.core.security.service.JwtService; @@ -22,6 +24,7 @@ import net.teumteum.user.domain.User; import net.teumteum.user.domain.UserFixture; import net.teumteum.user.domain.request.UserRegisterRequest; +import net.teumteum.user.domain.request.UserWithdrawRequest; import net.teumteum.user.domain.response.UserRegisterResponse; import net.teumteum.user.service.UserService; import org.junit.jupiter.api.BeforeEach; @@ -35,7 +38,6 @@ import org.springframework.context.annotation.FilterType; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.servlet.MockMvc; -import org.testcontainers.shaded.com.fasterxml.jackson.databind.ObjectMapper; @WebMvcTest(value = UserController.class, excludeFilters = {@ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = SecurityConfig.class), @@ -48,8 +50,10 @@ public class UserControllerTest { @Autowired - private MockMvc mockMvc; + ObjectMapper objectMapper; + @Autowired + private MockMvc mockMvc; @MockBean private UserService userService; @@ -79,7 +83,7 @@ void Register_user_card_with_201_created() throws Exception { // when & then mockMvc.perform(post("/users") - .content(new ObjectMapper().writeValueAsString(request)) + .content(objectMapper.writeValueAsString(request)) .contentType(APPLICATION_JSON) .with(csrf()) .header(AUTHORIZATION, VALID_ACCESS_TOKEN)) @@ -98,7 +102,7 @@ void Register_user_card_with_400_bad_request() throws Exception { // when // then mockMvc.perform(post("/users") - .content(new ObjectMapper().writeValueAsString(request)) + .content(objectMapper.writeValueAsString(request)) .contentType(APPLICATION_JSON) .with(csrf()) .header(AUTHORIZATION, VALID_ACCESS_TOKEN)) @@ -107,4 +111,26 @@ void Register_user_card_with_400_bad_request() throws Exception { .andExpect(jsonPath("$.message").isNotEmpty()); } } + + @Nested + @DisplayName("회원 탈퇴 API는") + class Withdraw_user_api_unit { + + @Test + @DisplayName("회원 탈퇴 사유와 회원 탈퇴 요청이 들어오면, 탈퇴를 진행하고 200 OK을 반환한다.") + void Withdraw_user_with_200_ok() throws Exception { + // given + UserWithdrawRequest request + = RequestFixture.userWithdrawRequest(List.of("쓰지 않는 앱이에요", "오류가 생겨서 쓸 수 없어요")); + + // when & then + mockMvc.perform(post("/users/withdraw") + .content(new ObjectMapper().writeValueAsString(request)) + .contentType(APPLICATION_JSON) + .with(csrf()) + .header(AUTHORIZATION, VALID_ACCESS_TOKEN)) + .andDo(print()) + .andExpect(status().isOk()); + } + } } diff --git a/src/test/java/net/teumteum/unit/user/service/UserServiceTest.java b/src/test/java/net/teumteum/unit/user/service/UserServiceTest.java index 87ffb254..f3dc6295 100644 --- a/src/test/java/net/teumteum/unit/user/service/UserServiceTest.java +++ b/src/test/java/net/teumteum/unit/user/service/UserServiceTest.java @@ -5,8 +5,15 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import java.util.List; +import java.util.Optional; import net.teumteum.auth.domain.response.TokenResponse; import net.teumteum.core.security.service.JwtService; import net.teumteum.core.security.service.RedisService; @@ -14,7 +21,9 @@ import net.teumteum.user.domain.User; import net.teumteum.user.domain.UserFixture; import net.teumteum.user.domain.UserRepository; +import net.teumteum.user.domain.WithdrawReasonRepository; import net.teumteum.user.domain.request.UserRegisterRequest; +import net.teumteum.user.domain.request.UserWithdrawRequest; import net.teumteum.user.domain.response.UserRegisterResponse; import net.teumteum.user.service.UserService; import org.junit.jupiter.api.BeforeEach; @@ -36,6 +45,9 @@ public class UserServiceTest { @Mock UserRepository userRepository; + @Mock + WithdrawReasonRepository withdrawReasonRepository; + @Mock RedisService redisService; @@ -89,4 +101,30 @@ void If_user_already_exist_register_user_card_fail() { .hasMessage("일치하는 user 가 이미 존재합니다."); } } + + @Nested + @DisplayName("유저 탈퇴 API는") + class Withdraw_user_api_unit { + + @Test + @DisplayName("유효한 유저 회원 탈퇴 요청이 들어오는 경우, 회원을 탈퇴하고 탈퇴 사유 데이터를 저장한다.") + void If_valid_user_withdraw_request_withdraw_user() { + // given + UserWithdrawRequest request + = RequestFixture.userWithdrawRequest(List.of("쓰지 않는 앱이에요", "오류가 생겨서 쓸 수 없어요")); + + given(userRepository.findById(anyLong())) + .willReturn(Optional.ofNullable(user)); + + doNothing().when(userRepository).delete(any()); + + doNothing().when(redisService).deleteData(anyString()); + // when + userService.withdraw(request, user.getId()); + // then + verify(userRepository, times(1)).findById(anyLong()); + verify(redisService, times(1)).deleteData(anyString()); + verify(withdrawReasonRepository, times(1)).saveAll(any()); + } + } } diff --git a/src/test/resources/schema.sql b/src/test/resources/schema.sql index 1304b85a..41f35219 100644 --- a/src/test/resources/schema.sql +++ b/src/test/resources/schema.sql @@ -68,3 +68,12 @@ create table if not exists users_friends friends bigint not null, foreign key (users_id) references users (id) ); + +create table if not exists withdraw_reasons +( + id bigint not null auto_increment, + withdraw_reason varchar(30) not null, + created_at timestamp(6) not null, + updated_at timestamp(6) not null, + primary key (id) +);