diff --git a/src/main/java/konkuk/thip/common/entity/BaseDomainEntity.java b/src/main/java/konkuk/thip/common/entity/BaseDomainEntity.java index d4948cb94..fd0f1257b 100644 --- a/src/main/java/konkuk/thip/common/entity/BaseDomainEntity.java +++ b/src/main/java/konkuk/thip/common/entity/BaseDomainEntity.java @@ -14,4 +14,12 @@ public class BaseDomainEntity { private LocalDateTime modifiedAt; private StatusType status; + + protected void changeStatus() { + if (this.status == StatusType.ACTIVE) { + this.status = StatusType.INACTIVE; + } else { + this.status = StatusType.ACTIVE; + } + } } diff --git a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java index 52b8953a3..fa5614ef7 100644 --- a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java +++ b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java @@ -37,6 +37,16 @@ public enum ErrorCode implements ResponseCode { * 70000 : user error */ USER_NOT_FOUND(HttpStatus.NOT_FOUND, 70000, "존재하지 않는 USER 입니다."), + USER_ALREADY_FOLLOWED(HttpStatus.BAD_REQUEST, 70001, "이미 팔로우한 사용자입니다."), + + /** + * 75000 : follow error + */ + FOLLOW_NOT_FOUND(HttpStatus.NOT_FOUND, 75001, "존재하지 않는 FOLLOW 입니다."), + USER_ALREADY_UNFOLLOWED(HttpStatus.BAD_REQUEST, 75002, "이미 언팔로우한 사용자입니다."), + USER_CANNOT_FOLLOW_SELF(HttpStatus.BAD_REQUEST, 75003, "사용자는 자신을 팔로우할 수 없습니다."), + + /** * 80000 : book error diff --git a/src/main/java/konkuk/thip/user/adapter/in/web/UserCommandController.java b/src/main/java/konkuk/thip/user/adapter/in/web/UserCommandController.java index 55ca9a027..e09821ab4 100644 --- a/src/main/java/konkuk/thip/user/adapter/in/web/UserCommandController.java +++ b/src/main/java/konkuk/thip/user/adapter/in/web/UserCommandController.java @@ -4,14 +4,19 @@ import jakarta.validation.Valid; import konkuk.thip.common.dto.BaseResponse; import konkuk.thip.common.security.annotation.Oauth2Id; +import konkuk.thip.common.security.annotation.UserId; import konkuk.thip.common.security.util.JwtUtil; +import konkuk.thip.user.adapter.in.web.request.UserFollowRequest; import konkuk.thip.user.adapter.in.web.request.UserSignupRequest; import konkuk.thip.user.adapter.in.web.request.UserVerifyNicknameRequest; +import konkuk.thip.user.adapter.in.web.response.UserFollowResponse; import konkuk.thip.user.adapter.in.web.response.UserSignupResponse; import konkuk.thip.user.adapter.in.web.response.UserVerifyNicknameResponse; +import konkuk.thip.user.application.port.in.UserFollowUsecase; import konkuk.thip.user.application.port.in.UserSignupUseCase; import konkuk.thip.user.application.port.in.UserVerifyNicknameUseCase; import lombok.RequiredArgsConstructor; +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.RestController; @@ -25,11 +30,12 @@ public class UserCommandController { private final UserSignupUseCase userSignupUseCase; private final UserVerifyNicknameUseCase userVerifyNicknameUseCase; + private final UserFollowUsecase userFollowUsecase; private final JwtUtil jwtUtil; @PostMapping("/users/signup") - public BaseResponse signup(@Valid @RequestBody UserSignupRequest request, - @Oauth2Id String oauth2Id, + public BaseResponse signup(@Valid @RequestBody final UserSignupRequest request, + @Oauth2Id final String oauth2Id, HttpServletResponse response) { Long userId = userSignupUseCase.signup(request.toCommand(oauth2Id)); String accessToken = jwtUtil.createAccessToken(userId); @@ -38,9 +44,19 @@ public BaseResponse signup(@Valid @RequestBody UserSignupReq } @PostMapping("/users/nickname") - public BaseResponse verifyNickname(@Valid @RequestBody UserVerifyNicknameRequest request) { + public BaseResponse verifyNickname(@Valid @RequestBody final UserVerifyNicknameRequest request) { return BaseResponse.ok(UserVerifyNicknameResponse.of( userVerifyNicknameUseCase.isNicknameUnique(request.nickname())) ); } + + // 팔루우 상태 변경 : true -> 팔로우, false -> 언팔로우 + @PostMapping("/users/following/{followingUserId}") + public BaseResponse followUser(@UserId final Long userId, + @PathVariable final Long followingUserId, + @RequestBody @Valid final UserFollowRequest request) { + return BaseResponse.ok(UserFollowResponse.of(userFollowUsecase.changeFollowingState( + UserFollowRequest.toCommand(userId, followingUserId, request.type()) + ))); + } } diff --git a/src/main/java/konkuk/thip/user/adapter/in/web/request/UserFollowRequest.java b/src/main/java/konkuk/thip/user/adapter/in/web/request/UserFollowRequest.java new file mode 100644 index 000000000..48f4d916d --- /dev/null +++ b/src/main/java/konkuk/thip/user/adapter/in/web/request/UserFollowRequest.java @@ -0,0 +1,13 @@ +package konkuk.thip.user.adapter.in.web.request; + +import jakarta.validation.constraints.NotNull; +import konkuk.thip.user.application.port.in.dto.UserFollowCommand; + +public record UserFollowRequest( + @NotNull(message = "type은 필수 파라미터입니다.") + Boolean type // true -> 팔로우, false -> 언팔로우 +) { + public static UserFollowCommand toCommand(Long userId, Long targetUserId, Boolean type) { + return new UserFollowCommand(userId, targetUserId, type); + } +} diff --git a/src/main/java/konkuk/thip/user/adapter/in/web/response/UserFollowResponse.java b/src/main/java/konkuk/thip/user/adapter/in/web/response/UserFollowResponse.java new file mode 100644 index 000000000..db34baaa1 --- /dev/null +++ b/src/main/java/konkuk/thip/user/adapter/in/web/response/UserFollowResponse.java @@ -0,0 +1,9 @@ +package konkuk.thip.user.adapter.in.web.response; + +public record UserFollowResponse( + boolean isFollowing +) { + public static UserFollowResponse of(boolean isFollowing) { + return new UserFollowResponse(isFollowing); + } +} diff --git a/src/main/java/konkuk/thip/user/adapter/out/jpa/FollowingJpaEntity.java b/src/main/java/konkuk/thip/user/adapter/out/jpa/FollowingJpaEntity.java index ef4f6b682..c457388a0 100644 --- a/src/main/java/konkuk/thip/user/adapter/out/jpa/FollowingJpaEntity.java +++ b/src/main/java/konkuk/thip/user/adapter/out/jpa/FollowingJpaEntity.java @@ -3,10 +3,12 @@ import jakarta.persistence.*; import konkuk.thip.common.entity.BaseJpaEntity; import lombok.*; +import org.hibernate.annotations.SQLDelete; @Entity @Table(name = "followings") @Getter +@SQLDelete(sql = "UPDATE followings SET status = 'INACTIVE' WHERE following_id = ?") @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor @Builder diff --git a/src/main/java/konkuk/thip/user/adapter/out/persistence/FollowingCommandPersistenceAdapter.java b/src/main/java/konkuk/thip/user/adapter/out/persistence/FollowingCommandPersistenceAdapter.java new file mode 100644 index 000000000..caae803cc --- /dev/null +++ b/src/main/java/konkuk/thip/user/adapter/out/persistence/FollowingCommandPersistenceAdapter.java @@ -0,0 +1,50 @@ +package konkuk.thip.user.adapter.out.persistence; + +import konkuk.thip.common.exception.EntityNotFoundException; +import konkuk.thip.user.adapter.out.jpa.FollowingJpaEntity; +import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.adapter.out.mapper.FollowingMapper; +import konkuk.thip.user.application.port.out.FollowingCommandPort; +import konkuk.thip.user.domain.Following; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +import static konkuk.thip.common.exception.code.ErrorCode.FOLLOW_NOT_FOUND; +import static konkuk.thip.common.exception.code.ErrorCode.USER_NOT_FOUND; + +@Repository +@RequiredArgsConstructor +public class FollowingCommandPersistenceAdapter implements FollowingCommandPort { + + private final FollowingJpaRepository followingJpaRepository; + private final UserJpaRepository userJpaRepository; + + private final FollowingMapper followingMapper; + + @Override + public Optional findByUserIdAndTargetUserId(Long userId, Long targetUserId) { + Optional followingJpaEntity = followingJpaRepository.findByUserAndTargetUser(userId, targetUserId); + return followingJpaEntity.map(followingMapper::toDomainEntity); + } + + @Override + public void save(Following following) { // insert용 + UserJpaEntity userJpaEntity = userJpaRepository.findById(following.getUserId()).orElseThrow( + () -> new EntityNotFoundException(USER_NOT_FOUND)); + + UserJpaEntity targetUser = userJpaRepository.findById(following.getFollowingUserId()).orElseThrow( + () -> new EntityNotFoundException(USER_NOT_FOUND)); + + followingJpaRepository.save(followingMapper.toJpaEntity(userJpaEntity, targetUser)); + } + + @Override + public void updateStatus(Following following) { // 상태변경 용 + FollowingJpaEntity entity = followingJpaRepository.findByUserAndTargetUser(following.getUserId(), following.getFollowingUserId()) + .orElseThrow(() -> new EntityNotFoundException(FOLLOW_NOT_FOUND)); + + entity.setStatus(following.getStatus()); + } +} diff --git a/src/main/java/konkuk/thip/user/adapter/out/persistence/FollowingJpaRepository.java b/src/main/java/konkuk/thip/user/adapter/out/persistence/FollowingJpaRepository.java index f0ded99ef..dd3ba397f 100644 --- a/src/main/java/konkuk/thip/user/adapter/out/persistence/FollowingJpaRepository.java +++ b/src/main/java/konkuk/thip/user/adapter/out/persistence/FollowingJpaRepository.java @@ -2,6 +2,10 @@ import konkuk.thip.user.adapter.out.jpa.FollowingJpaEntity; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface FollowingJpaRepository extends JpaRepository, FollowingQueryRepository { + -public interface FollowingJpaRepository extends JpaRepository,FollowingQueryRepository { } diff --git a/src/main/java/konkuk/thip/user/adapter/out/persistence/FollowingQueryRepository.java b/src/main/java/konkuk/thip/user/adapter/out/persistence/FollowingQueryRepository.java index 118cc8a53..fd98149f6 100644 --- a/src/main/java/konkuk/thip/user/adapter/out/persistence/FollowingQueryRepository.java +++ b/src/main/java/konkuk/thip/user/adapter/out/persistence/FollowingQueryRepository.java @@ -1,8 +1,12 @@ package konkuk.thip.user.adapter.out.persistence; +import konkuk.thip.user.adapter.out.jpa.FollowingJpaEntity; + +import java.util.Optional; import java.util.List; import java.util.Map; public interface FollowingQueryRepository { Map countByFollowingUserIds(List userIds); + Optional findByUserAndTargetUser(Long userId, Long targetUserId); } diff --git a/src/main/java/konkuk/thip/user/adapter/out/persistence/FollowingQueryRepositoryImpl.java b/src/main/java/konkuk/thip/user/adapter/out/persistence/FollowingQueryRepositoryImpl.java index cec44bc76..63d044498 100644 --- a/src/main/java/konkuk/thip/user/adapter/out/persistence/FollowingQueryRepositoryImpl.java +++ b/src/main/java/konkuk/thip/user/adapter/out/persistence/FollowingQueryRepositoryImpl.java @@ -2,10 +2,12 @@ import com.querydsl.core.Tuple; import com.querydsl.jpa.impl.JPAQueryFactory; +import konkuk.thip.user.adapter.out.jpa.FollowingJpaEntity; import konkuk.thip.user.adapter.out.jpa.QFollowingJpaEntity; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import java.util.Optional; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -35,4 +37,17 @@ public Map countByFollowingUserIds(List userIds) { tuple -> tuple.get(following.count()).intValue() )); } + + @Override + public Optional findByUserAndTargetUser(Long userId, Long targetUserId) { + QFollowingJpaEntity following = QFollowingJpaEntity.followingJpaEntity; + + FollowingJpaEntity followingJpaEntity = jpaQueryFactory + .selectFrom(following) + .where(following.userJpaEntity.userId.eq(userId) + .and(following.followingUserJpaEntity.userId.eq(targetUserId))) + .fetchOne(); + + return Optional.ofNullable(followingJpaEntity); + } } diff --git a/src/main/java/konkuk/thip/user/application/port/in/UserFollowUsecase.java b/src/main/java/konkuk/thip/user/application/port/in/UserFollowUsecase.java new file mode 100644 index 000000000..6f3aa9ed7 --- /dev/null +++ b/src/main/java/konkuk/thip/user/application/port/in/UserFollowUsecase.java @@ -0,0 +1,8 @@ +package konkuk.thip.user.application.port.in; + + +import konkuk.thip.user.application.port.in.dto.UserFollowCommand; + +public interface UserFollowUsecase { + Boolean changeFollowingState(UserFollowCommand followCommand); +} diff --git a/src/main/java/konkuk/thip/user/application/port/in/dto/UserFollowCommand.java b/src/main/java/konkuk/thip/user/application/port/in/dto/UserFollowCommand.java new file mode 100644 index 000000000..a53eff480 --- /dev/null +++ b/src/main/java/konkuk/thip/user/application/port/in/dto/UserFollowCommand.java @@ -0,0 +1,8 @@ +package konkuk.thip.user.application.port.in.dto; + +public record UserFollowCommand( + Long userId, + Long targetUserId, + Boolean type // true -> 팔로우, false -> 언팔로우 +) { +} diff --git a/src/main/java/konkuk/thip/user/application/port/out/FollowingCommandPort.java b/src/main/java/konkuk/thip/user/application/port/out/FollowingCommandPort.java new file mode 100644 index 000000000..7f7ad8ac8 --- /dev/null +++ b/src/main/java/konkuk/thip/user/application/port/out/FollowingCommandPort.java @@ -0,0 +1,14 @@ +package konkuk.thip.user.application.port.out; + +import konkuk.thip.user.domain.Following; + +import java.util.Optional; + +public interface FollowingCommandPort { + + Optional findByUserIdAndTargetUserId(Long userId, Long targetUserId); + + void save(Following following); + + void updateStatus(Following following); +} diff --git a/src/main/java/konkuk/thip/user/application/service/UserFollowService.java b/src/main/java/konkuk/thip/user/application/service/UserFollowService.java new file mode 100644 index 000000000..507459603 --- /dev/null +++ b/src/main/java/konkuk/thip/user/application/service/UserFollowService.java @@ -0,0 +1,54 @@ +package konkuk.thip.user.application.service; + +import konkuk.thip.common.exception.InvalidStateException; +import konkuk.thip.user.application.port.in.UserFollowUsecase; +import konkuk.thip.user.application.port.in.dto.UserFollowCommand; +import konkuk.thip.user.application.port.out.FollowingCommandPort; +import konkuk.thip.user.domain.Following; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Objects; +import java.util.Optional; + +import static konkuk.thip.common.exception.code.ErrorCode.USER_ALREADY_UNFOLLOWED; +import static konkuk.thip.common.exception.code.ErrorCode.USER_CANNOT_FOLLOW_SELF; + +@Service +@RequiredArgsConstructor +public class UserFollowService implements UserFollowUsecase { + + private final FollowingCommandPort followingCommandPort; + + @Override + @Transactional + public Boolean changeFollowingState(UserFollowCommand followCommand) { + Long userId = followCommand.userId(); + Long targetUserId = followCommand.targetUserId(); + Boolean type = followCommand.type(); + + validateParams(userId, targetUserId); + + Optional optionalFollowing = followingCommandPort.findByUserIdAndTargetUserId(userId, targetUserId); + + if (optionalFollowing.isPresent()) { // 이미 팔로우 관계가 존재하는 경우 + Following following = optionalFollowing.get(); + boolean isFollowing = following.changeFollowingState(type); + followingCommandPort.updateStatus(following); + return isFollowing; + } else { // 팔로우 관계가 존재하지 않는 경우 + if (!type) { + throw new InvalidStateException(USER_ALREADY_UNFOLLOWED); // 언팔로우 요청인데 팔로우 관계가 존재하지 않으므로 이미 언팔로우 상태 + } + followingCommandPort.save(Following.withoutId(userId, targetUserId)); + return true; // 새로 팔로우한 경우 + } + } + + private void validateParams(Long userId, Long targetUserId) { + if(Objects.equals(userId, targetUserId)) { + throw new InvalidStateException(USER_CANNOT_FOLLOW_SELF); + } + } +} diff --git a/src/main/java/konkuk/thip/user/domain/Following.java b/src/main/java/konkuk/thip/user/domain/Following.java index 8e50baad9..6ffa7efb2 100644 --- a/src/main/java/konkuk/thip/user/domain/Following.java +++ b/src/main/java/konkuk/thip/user/domain/Following.java @@ -1,9 +1,14 @@ package konkuk.thip.user.domain; import konkuk.thip.common.entity.BaseDomainEntity; +import konkuk.thip.common.entity.StatusType; +import konkuk.thip.common.exception.InvalidStateException; import lombok.Getter; import lombok.experimental.SuperBuilder; +import static konkuk.thip.common.exception.code.ErrorCode.USER_ALREADY_FOLLOWED; +import static konkuk.thip.common.exception.code.ErrorCode.USER_ALREADY_UNFOLLOWED; + @Getter @SuperBuilder public class Following extends BaseDomainEntity { @@ -13,4 +18,30 @@ public class Following extends BaseDomainEntity { private Long userId; private Long followingUserId; + + public static Following withoutId(Long userId, Long followingUserId) { + return Following.builder() + .userId(userId) + .followingUserId(followingUserId) + .status(StatusType.ACTIVE) + .build(); + } + + public boolean changeFollowingState(boolean isFollowRequest) { + StatusType currentStatus = getStatus(); + validateFollowingState(isFollowRequest, currentStatus); + + super.changeStatus(); + return isFollowRequest; + } + + private void validateFollowingState(boolean isFollowRequest, StatusType currentStatus) { + if (isFollowRequest && currentStatus == StatusType.ACTIVE) { // 팔로우 요청일 때 이미 팔로우 중인 경우 + throw new InvalidStateException(USER_ALREADY_FOLLOWED); + } + + if (!isFollowRequest && currentStatus == StatusType.INACTIVE) { // 언팔로우 요청일 때 이미 언팔로우 중인 경우 + throw new InvalidStateException(USER_ALREADY_UNFOLLOWED); + } + } } diff --git a/src/test/java/konkuk/thip/room/adapter/in/web/RoomGetMemberListApiTest.java b/src/test/java/konkuk/thip/room/adapter/in/web/RoomGetMemberListApiTest.java index 40d645f0f..8ef260db4 100644 --- a/src/test/java/konkuk/thip/room/adapter/in/web/RoomGetMemberListApiTest.java +++ b/src/test/java/konkuk/thip/room/adapter/in/web/RoomGetMemberListApiTest.java @@ -99,7 +99,7 @@ void setUp() { @AfterEach void tearDown() { - followingJpaRepository.deleteAll(); + followingJpaRepository.deleteAllInBatch(); userRoomJpaRepository.deleteAll(); roomJpaRepository.deleteAll(); bookJpaRepository.deleteAll(); diff --git a/src/test/java/konkuk/thip/user/adapter/in/web/UserFollowApiTest.java b/src/test/java/konkuk/thip/user/adapter/in/web/UserFollowApiTest.java new file mode 100644 index 000000000..3ceb2109f --- /dev/null +++ b/src/test/java/konkuk/thip/user/adapter/in/web/UserFollowApiTest.java @@ -0,0 +1,101 @@ +package konkuk.thip.user.adapter.in.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import konkuk.thip.common.util.TestEntityFactory; +import konkuk.thip.user.adapter.out.jpa.AliasJpaEntity; +import konkuk.thip.user.adapter.out.jpa.FollowingJpaEntity; +import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.adapter.out.jpa.UserRole; +import konkuk.thip.user.adapter.out.persistence.AliasJpaRepository; +import konkuk.thip.user.adapter.out.persistence.FollowingJpaRepository; +import konkuk.thip.user.adapter.out.persistence.UserJpaRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@ActiveProfiles("test") +@AutoConfigureMockMvc(addFilters = false) +@DisplayName("[통합] 팔로잉 상태 변경 API 통합 테스트") +class UserFollowApiTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private UserJpaRepository userJpaRepository; + + @Autowired + private AliasJpaRepository aliasJpaRepository; + + @Autowired + private FollowingJpaRepository followingJpaRepository; + + @AfterEach + void tearDown() { + followingJpaRepository.deleteAllInBatch(); + userJpaRepository.deleteAll(); + aliasJpaRepository.deleteAll(); + } + + @Test + @DisplayName("팔로우 요청 후 언팔로우 요청 시 상태가 변경되는지 확인한다.") + void changeFollowingState_follow_then_unfollow() throws Exception { + // 사용자 2명 저장 + AliasJpaEntity alias = aliasJpaRepository.save(TestEntityFactory.createScienceAlias()); + + UserJpaEntity user = userJpaRepository.save(UserJpaEntity.builder() + .nickname("user100") + .imageUrl("http://image") + .oauth2Id("oauth2_user100") + .role(UserRole.USER) + .aliasForUserJpaEntity(alias) + .build()); + + UserJpaEntity target = userJpaRepository.save(UserJpaEntity.builder() + .nickname("user200") + .imageUrl("http://image") + .oauth2Id("oauth2_user200") + .role(UserRole.USER) + .aliasForUserJpaEntity(alias) + .build()); + + // 팔로우 요청 + mockMvc.perform(post("/users/following/{followingUserId}", target.getUserId()) + .requestAttr("userId", user.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"type\": true}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.isFollowing").value(true)); + + // DB에 팔로우 상태가 ACTIVE로 저장되었는지 확인 + FollowingJpaEntity followEntity = followingJpaRepository.findByUserAndTargetUser(user.getUserId(), target.getUserId()).orElseThrow(); + assertThat(followEntity.getStatus().name()).isEqualTo("ACTIVE"); + + // 언팔로우 요청 + mockMvc.perform(post("/users/following/{followingUserId}", target.getUserId()) + .requestAttr("userId", user.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"type\": false}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.isFollowing").value(false)); + + // DB에 상태가 INACTIVE로 변경되었는지 확인 + FollowingJpaEntity updatedEntity = followingJpaRepository.findByUserAndTargetUser(user.getUserId(), target.getUserId()).orElseThrow(); + assertThat(updatedEntity.getStatus().name()).isEqualTo("INACTIVE"); + } +} \ No newline at end of file diff --git a/src/test/java/konkuk/thip/user/adapter/in/web/UserFollowControllerTest.java b/src/test/java/konkuk/thip/user/adapter/in/web/UserFollowControllerTest.java new file mode 100644 index 000000000..16aec5cbf --- /dev/null +++ b/src/test/java/konkuk/thip/user/adapter/in/web/UserFollowControllerTest.java @@ -0,0 +1,57 @@ +package konkuk.thip.user.adapter.in.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import konkuk.thip.common.exception.code.ErrorCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.HashMap; +import java.util.Map; + +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@ActiveProfiles("test") +@AutoConfigureMockMvc(addFilters = false) +@DisplayName("[단위] 팔로잉 상태 변경 API controller 단위 테스트") +class UserFollowControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + private Map buildValidRequest() { + Map req = new HashMap<>(); + req.put("type", true); + return req; + } + + private void assertBad(Map req, String msg) throws Exception { + mockMvc.perform(post("/users/following/{followingUserId}", 2L) + .requestAttr("userId", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.API_INVALID_PARAM.getCode())) + .andExpect(jsonPath("$.message", containsString(msg))); + } + + @Test + @DisplayName("type이 null이면 400 에러") + void null_type() throws Exception { + Map req = new HashMap<>(); // type 없음 + assertBad(req, "type은 필수 파라미터입니다."); + } + +} \ No newline at end of file diff --git a/src/test/java/konkuk/thip/user/adapter/in/web/UserSignupControllerTest.java b/src/test/java/konkuk/thip/user/adapter/in/web/UserSignupControllerTest.java index 2d788ed70..ecf1d2172 100644 --- a/src/test/java/konkuk/thip/user/adapter/in/web/UserSignupControllerTest.java +++ b/src/test/java/konkuk/thip/user/adapter/in/web/UserSignupControllerTest.java @@ -31,7 +31,7 @@ @SpringBootTest @ActiveProfiles("test") @AutoConfigureMockMvc -@DisplayName("[통합] UserSignupController 테스트") +@DisplayName("[통합] 회원가입 api 테스트") class UserSignupControllerTest { @Autowired diff --git a/src/test/java/konkuk/thip/user/adapter/in/web/UserVerifyNicknameControllerTest.java b/src/test/java/konkuk/thip/user/adapter/in/web/UserVerifyNicknameControllerTest.java index 69b2a9c84..fe36a4bd5 100644 --- a/src/test/java/konkuk/thip/user/adapter/in/web/UserVerifyNicknameControllerTest.java +++ b/src/test/java/konkuk/thip/user/adapter/in/web/UserVerifyNicknameControllerTest.java @@ -30,7 +30,7 @@ @SpringBootTest @ActiveProfiles("test") @AutoConfigureMockMvc(addFilters = false) -@DisplayName("[통합] UserVerifyNicknameController 테스트") +@DisplayName("[통합] 닉네임 중복 검증 api 테스트") class UserVerifyNicknameControllerTest { @Autowired diff --git a/src/test/java/konkuk/thip/user/adapter/in/web/UserViewAliasChoiceControllerTest.java b/src/test/java/konkuk/thip/user/adapter/in/web/UserViewAliasChoiceControllerTest.java index 3f73a4610..ee5c153b3 100644 --- a/src/test/java/konkuk/thip/user/adapter/in/web/UserViewAliasChoiceControllerTest.java +++ b/src/test/java/konkuk/thip/user/adapter/in/web/UserViewAliasChoiceControllerTest.java @@ -30,7 +30,7 @@ @SpringBootTest @ActiveProfiles("test") @AutoConfigureMockMvc(addFilters = false) -@DisplayName("[통합] UserViewAliasChoiceController 테스트") +@DisplayName("[통합] 사용자 칭호 선택 api 테스트") class UserViewAliasChoiceControllerTest { @Autowired diff --git a/src/test/java/konkuk/thip/user/application/service/UserFollowServiceTest.java b/src/test/java/konkuk/thip/user/application/service/UserFollowServiceTest.java new file mode 100644 index 000000000..2cdc2b62d --- /dev/null +++ b/src/test/java/konkuk/thip/user/application/service/UserFollowServiceTest.java @@ -0,0 +1,145 @@ +package konkuk.thip.user.application.service; + +import konkuk.thip.common.exception.InvalidStateException; +import konkuk.thip.user.application.port.in.dto.UserFollowCommand; +import konkuk.thip.user.application.port.out.FollowingCommandPort; +import konkuk.thip.user.domain.Following; +import konkuk.thip.common.entity.StatusType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.Optional; + +import static konkuk.thip.common.exception.code.ErrorCode.USER_ALREADY_UNFOLLOWED; +import static konkuk.thip.common.exception.code.ErrorCode.USER_CANNOT_FOLLOW_SELF; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +class UserFollowServiceTest { + + private FollowingCommandPort followingCommandPort; + private UserFollowService userFollowService; + + @BeforeEach + void setUp() { + followingCommandPort = mock(FollowingCommandPort.class); + userFollowService = new UserFollowService(followingCommandPort); + } + + @Nested + @DisplayName("팔로우 요청(type = true)") + class Follow { + + @Test + @DisplayName("기존 inactive row가 존재하면 active로 변경") + void activate_existingFollowing() { + // given + Long userId = 1L, targetUserId = 2L; + Following inactiveFollowing = Following.builder() + .id(10L) + .userId(userId) + .followingUserId(targetUserId) + .status(StatusType.INACTIVE) + .build(); + + when(followingCommandPort.findByUserIdAndTargetUserId(userId, targetUserId)) + .thenReturn(Optional.of(inactiveFollowing)); + + UserFollowCommand command = new UserFollowCommand(userId, targetUserId, true); + + // when + Boolean result = userFollowService.changeFollowingState(command); + + // then + assertThat(result).isTrue(); + assertThat(inactiveFollowing.getStatus()).isEqualTo(StatusType.ACTIVE); + verify(followingCommandPort).updateStatus(inactiveFollowing); + } + + @Test + @DisplayName("팔로우 관계가 존재하지 않으면 새로 생성") + void create_newFollowing() { + // given + Long userId = 1L, targetUserId = 2L; + when(followingCommandPort.findByUserIdAndTargetUserId(userId, targetUserId)) + .thenReturn(Optional.empty()); + + UserFollowCommand command = new UserFollowCommand(userId, targetUserId, true); + + // when + Boolean result = userFollowService.changeFollowingState(command); + + // then + assertThat(result).isTrue(); + ArgumentCaptor captor = ArgumentCaptor.forClass(Following.class); + verify(followingCommandPort).save(captor.capture()); + + Following saved = captor.getValue(); + assertThat(saved.getUserId()).isEqualTo(userId); + assertThat(saved.getFollowingUserId()).isEqualTo(targetUserId); + assertThat(saved.getStatus()).isEqualTo(StatusType.ACTIVE); + } + } + + @Nested + @DisplayName("언팔로우 요청(type = false)") + class Unfollow { + + @Test + @DisplayName("active row가 존재하면 inactive로 변경") + void deactivate_existingFollowing() { + // given + Long userId = 1L, targetUserId = 2L; + Following activeFollowing = Following.builder() + .id(10L) + .userId(userId) + .followingUserId(targetUserId) + .status(StatusType.ACTIVE) + .build(); + + when(followingCommandPort.findByUserIdAndTargetUserId(userId, targetUserId)) + .thenReturn(Optional.of(activeFollowing)); + + UserFollowCommand command = new UserFollowCommand(userId, targetUserId, false); + + // when + Boolean result = userFollowService.changeFollowingState(command); + + // then + assertThat(result).isFalse(); + assertThat(activeFollowing.getStatus()).isEqualTo(StatusType.INACTIVE); + verify(followingCommandPort).updateStatus(activeFollowing); + } + + @Test + @DisplayName("언팔로우 요청인데 팔로우 관계가 없으면 예외 발생") + void unfollow_withoutRelation() { + // given + Long userId = 1L, targetUserId = 2L; + when(followingCommandPort.findByUserIdAndTargetUserId(userId, targetUserId)) + .thenReturn(Optional.empty()); + + UserFollowCommand command = new UserFollowCommand(userId, targetUserId, false); + + // when & then + assertThatThrownBy(() -> userFollowService.changeFollowingState(command)) + .isInstanceOf(InvalidStateException.class) + .hasMessageContaining(USER_ALREADY_UNFOLLOWED.getMessage()); + } + } + + @Test + @DisplayName("자기 자신을 팔로우하는 요청이면 예외 발생") + void cannot_follow_self() { + Long userId = 1L; + UserFollowCommand command = new UserFollowCommand(userId, userId, true); + + assertThatThrownBy(() -> userFollowService.changeFollowingState(command)) + .isInstanceOf(InvalidStateException.class) + .hasMessageContaining(USER_CANNOT_FOLLOW_SELF.getMessage()); + } +} \ No newline at end of file diff --git a/src/test/java/konkuk/thip/user/domain/FollowingTest.java b/src/test/java/konkuk/thip/user/domain/FollowingTest.java new file mode 100644 index 000000000..c4426ad7d --- /dev/null +++ b/src/test/java/konkuk/thip/user/domain/FollowingTest.java @@ -0,0 +1,93 @@ +package konkuk.thip.user.domain; + +import konkuk.thip.common.entity.StatusType; +import konkuk.thip.common.exception.InvalidStateException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static konkuk.thip.common.exception.code.ErrorCode.USER_ALREADY_FOLLOWED; +import static konkuk.thip.common.exception.code.ErrorCode.USER_ALREADY_UNFOLLOWED; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class FollowingTest { + + @Nested + @DisplayName("팔로우 요청") + class Follow { + + @Test + @DisplayName("inactive 상태에서 follow 요청 → active로 변경") + void follow_from_inactive() { + Following following = Following.builder() + .userId(1L) + .followingUserId(2L) + .status(StatusType.INACTIVE) + .build(); + + boolean result = following.changeFollowingState(true); + + assertThat(result).isTrue(); + assertThat(following.getStatus()).isEqualTo(StatusType.ACTIVE); + } + + @Test + @DisplayName("이미 active 상태에서 follow 요청 → 예외 발생") + void follow_from_active_should_throw() { + Following following = Following.builder() + .userId(1L) + .followingUserId(2L) + .status(StatusType.ACTIVE) + .build(); + + assertThatThrownBy(() -> following.changeFollowingState(true)) + .isInstanceOf(InvalidStateException.class) + .hasMessage(USER_ALREADY_FOLLOWED.getMessage()); + } + } + + @Nested + @DisplayName("언팔로우 요청") + class Unfollow { + + @Test + @DisplayName("active 상태에서 unfollow 요청 → inactive로 변경") + void unfollow_from_active() { + Following following = Following.builder() + .userId(1L) + .followingUserId(2L) + .status(StatusType.ACTIVE) + .build(); + + boolean result = following.changeFollowingState(false); + + assertThat(result).isFalse(); + assertThat(following.getStatus()).isEqualTo(StatusType.INACTIVE); + } + + @Test + @DisplayName("이미 inactive 상태에서 unfollow 요청 → 예외 발생") + void unfollow_from_inactive_should_throw() { + Following following = Following.builder() + .userId(1L) + .followingUserId(2L) + .status(StatusType.INACTIVE) + .build(); + + assertThatThrownBy(() -> following.changeFollowingState(false)) + .isInstanceOf(InvalidStateException.class) + .hasMessage(USER_ALREADY_UNFOLLOWED.getMessage()); + } + } + + @Test + @DisplayName("새로운 팔로우 생성 시 상태는 ACTIVE") + void create_following_should_be_active() { + Following following = Following.withoutId(1L, 2L); + + assertThat(following.getUserId()).isEqualTo(1L); + assertThat(following.getFollowingUserId()).isEqualTo(2L); + assertThat(following.getStatus()).isEqualTo(StatusType.ACTIVE); + } +} \ No newline at end of file