Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feat/39] 친구 목록 조회 API 구현 및 테스트 #40

Merged
merged 13 commits into from
Dec 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/workflows/pr_test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: PR Test

on:
pull_request:
branches: [ develop ] # develop branch에 PR을 보낼 때 실행

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3

- name: Set up JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'

# Gradle wrapper 파일 실행 권한주기
- name: Grant execute permission for gradlew
run: chmod +x gradlew

# Gradle test를 실행
- name: Test with Gradle
run: ./gradlew --info test
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ build/
!**/src/main/**/build/
!**/src/test/**/build/

/src/main/generated/**


# General
.DS_Store
.AppleDouble
Expand Down
27 changes: 23 additions & 4 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,34 @@ dependencies {
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'

// security
// implementation 'org.springframework.boot:spring-boot-starter-security'
// testImplementation 'org.springframework.security:spring-security-test'
// Querydsl
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"

developmentOnly 'org.springframework.boot:spring-boot-devtools'
// developmentOnly 'org.springframework.boot:spring-boot-docker-compose'

}

// Querydsl 설정부
def generated = 'src/main/generated'

// querydsl QClass 파일 생성 위치를 지정
tasks.withType(JavaCompile) {
options.getGeneratedSourceOutputDirectory().set(file(generated))
}

// java source set 에 querydsl QClass 위치 추가
sourceSets {
main.java.srcDirs += [generated]
}

// gradle clean 시에 QClass 디렉토리 삭제
clean {
delete file(generated)
}

tasks.named('test') {
useJUnitPlatform()
}
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ public void unBlockMember(Member member, Long targetMemberId) {
* @param member
* @param targetMemberId
*/
@Transactional
public void deleteBlock(Member member, Long targetMemberId) {
Member targetMember = memberService.findMember(targetMemberId);
blockService.deleteBlock(member, targetMember);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ public Page<Member> findBlockedMembersByBlockerId(Long blockerId, Pageable pagea
* @param member
* @param targetMember
*/
@Transactional
public void unBlockMember(Member member, Member targetMember) {
// 대상 회원의 탈퇴 여부 검증
memberValidator.validateTargetMemberIsNotBlind(targetMember);
Expand All @@ -85,6 +86,7 @@ public void unBlockMember(Member member, Member targetMember) {
* @param member
* @param targetMember
*/
@Transactional
public void deleteBlock(Member member, Member targetMember) {
// targetMember가 차단 목록에 존재하는지 검증 및 block 엔티티 조회
Block block = blockRepository.findByBlockerMemberAndBlockedMember(member, targetMember)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.gamegoo.gamegoo_v2.common.annotation;

import com.gamegoo.gamegoo_v2.common.annotationValidator.ValidCursorAnnotationValidator;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Constraint(validatedBy = ValidCursorAnnotationValidator.class)
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidCursor {

String message() default "커서는 1 이상의 값이어야 합니다.";

Class<?>[] groups() default {};

Class<? extends Payload>[] payload() default {};

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.gamegoo.gamegoo_v2.common.annotationValidator;

import com.gamegoo.gamegoo_v2.common.annotation.ValidCursor;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

public class ValidCursorAnnotationValidator implements ConstraintValidator<ValidCursor, Long> {

@Override
public void initialize(ValidCursor constraintAnnotation) {
ConstraintValidator.super.initialize(constraintAnnotation);
}

@Override
public boolean isValid(Long value, ConstraintValidatorContext context) {
if (value != null && value < 1) {
return false;
}

return true;
}

}
20 changes: 20 additions & 0 deletions src/main/java/com/gamegoo/gamegoo_v2/config/QuerydslConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.gamegoo.gamegoo_v2.config;

import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class QuerydslConfig {

@Autowired
private EntityManager em;

@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(em);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@
import jakarta.validation.ConstraintViolationException;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.HandlerMethodValidationException;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;

import java.util.List;
import java.util.stream.Collectors;

import static org.springframework.http.HttpStatus.BAD_REQUEST;
Expand Down Expand Up @@ -58,7 +61,7 @@ public ResponseEntity<ApiResponse<?>> handleHttpMessageNotReadableException(Http
.body(ApiResponse.of(BAD_REQUEST, "확인할 수 없는 형태의 데이터가 들어왔습니다"));
}

// @Validated 검증 실패 시 발생하는 에러
// 메소드 파라미터 검증 실패 시 발생하는 에러
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ApiResponse<?>> handlerConstraintViolationException(ConstraintViolationException e) {
// ConstraintViolationException에서 메시지 추출
Expand All @@ -69,6 +72,26 @@ public ResponseEntity<ApiResponse<?>> handlerConstraintViolationException(Constr
return ResponseEntity.badRequest().body(ApiResponse.of(BAD_REQUEST, errorMessage));
}

// 컨트롤러 @Validated 검증 실패 시 발생하는 에러
@ExceptionHandler(HandlerMethodValidationException.class)
public ResponseEntity<ApiResponse<?>> handlerHandlerMethodValidationException(HandlerMethodValidationException e) {
// 모든 제약 위반 메시지를 추출
List<String> errorMessages = e.getAllErrors().stream()
.map(error -> {
if (error instanceof FieldError) {
return error.getDefaultMessage();
} else {
return error.getDefaultMessage();
}
})
.collect(Collectors.toList());

// 첫 번째 오류 메시지만 반환
String responseMessage = String.join(", ", errorMessages);

return ResponseEntity.badRequest().body(ApiResponse.of(BAD_REQUEST, responseMessage));
}

// 필수 query parameter 누락 시 발생하는 에러
@ExceptionHandler(MissingServletRequestParameterException.class)
public ResponseEntity<ApiResponse<?>> handleMissingServletRequestParameterException(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

import com.gamegoo.gamegoo_v2.auth.annotation.AuthMember;
import com.gamegoo.gamegoo_v2.common.ApiResponse;
import com.gamegoo.gamegoo_v2.common.annotation.ValidCursor;
import com.gamegoo.gamegoo_v2.friend.dto.DeleteFriendResponse;
import com.gamegoo.gamegoo_v2.friend.dto.FriendListResponse;
import com.gamegoo.gamegoo_v2.friend.dto.FriendRequestResponse;
import com.gamegoo.gamegoo_v2.friend.dto.StarFriendResponse;
import com.gamegoo.gamegoo_v2.friend.service.FriendFacadeService;
Expand All @@ -11,17 +13,23 @@
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@Tag(name = "Friend", description = "친구 관련 API")
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v2/friends")
@Validated
public class FriendController {

private final FriendFacadeService friendFacadeService;
Expand Down Expand Up @@ -49,7 +57,7 @@ public ApiResponse<FriendRequestResponse> rejectFriendRequest(@PathVariable(name
@AuthMember Member member) {
return ApiResponse.ok(friendFacadeService.rejectFriendRequest(member, targetMemberId));
}

@Operation(summary = "친구 요청 취소 API", description = "대상 회원에게 보낸 친구 요청을 취소하는 API 입니다.")
@Parameter(name = "memberId", description = "친구 요청을 취소할 대상 회원의 id 입니다.")
@DeleteMapping("/request/{memberId}")
Expand All @@ -74,4 +82,20 @@ public ApiResponse<DeleteFriendResponse> deleteFriend(@PathVariable(name = "memb
return ApiResponse.ok(friendFacadeService.deleteFriend(member, targetMemberId));
}

@Operation(summary = "모든 친구 id 조회 API", description = "해당 회원의 모든 친구 id 목록을 조회하는 API 입니다. " +
"정렬 기능 없음, socket서버용 API입니다.")
@GetMapping("/ids")
public ApiResponse<List<Long>> getFriendIds(@AuthMember Member member) {
return ApiResponse.ok(friendFacadeService.getFriendIdList(member));
}

@Operation(summary = "친구 목록 조회 API", description = "해당 회원의 친구 목록을 조회하는 API 입니다. 이름 오름차순(한글-영문-숫자 순)으로 정렬해 제공합니다."
+ "cursor를 보내지 않으면 상위 10개 친구 목록을 조회합니다.")
@Parameter(name = "cursor", description = "페이징을 위한 커서, 이전 친구 목록 조회에서 응답받은 next_cursor를 보내주세요.")
@GetMapping
public ApiResponse<FriendListResponse> getFriendList(
@ValidCursor @RequestParam(name = "cursor", required = false) Long cursor, @AuthMember Member member) {
return ApiResponse.ok(friendFacadeService.getFriends(member, cursor));
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package com.gamegoo.gamegoo_v2.friend.dto;

import com.gamegoo.gamegoo_v2.friend.domain.Friend;
import lombok.Builder;
import lombok.Getter;
import org.springframework.data.domain.Slice;

import java.util.List;

@Getter
@Builder
public class FriendListResponse {

List<FriendInfoResponse> friendInfoDTOList;
int listSize;
boolean hasNext;
Long nextCursor;

@Getter
@Builder
public static class FriendInfoResponse {

Long memberId;
String name;
int profileImg;
boolean isLiked;
boolean isBlind;

public static FriendInfoResponse of(Friend friend) {
String name = friend.getToMember().isBlind() ? "(탈퇴한 사용자)" : friend.getToMember().getGameName();
return FriendInfoResponse.builder()
.memberId(friend.getToMember().getId())
.profileImg(friend.getToMember().getProfileImage())
.name(name)
.isLiked(friend.isLiked())
.isBlind(friend.getToMember().isBlind())
.build();
}

}

public static FriendListResponse of(Slice<Friend> friends) {
List<FriendInfoResponse> friendInfoResponseList = friends.stream()
.map(FriendInfoResponse::of)
.toList();

Long nextCursor = friends.hasNext()
? friends.getContent().get(friendInfoResponseList.size() - 1).getToMember().getId()
: null;

return FriendListResponse.builder()
.friendInfoDTOList(friendInfoResponseList)
.listSize(friendInfoResponseList.size())
.hasNext(friends.hasNext())
.nextCursor(nextCursor)
.build();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import com.gamegoo.gamegoo_v2.member.domain.Member;
import org.springframework.data.jpa.repository.JpaRepository;

public interface FriendRepository extends JpaRepository<Friend, Long> {
public interface FriendRepository extends JpaRepository<Friend, Long>, FriendRepositoryCustom {

boolean existsByFromMemberAndToMember(Member fromMember, Member toMember);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.gamegoo.gamegoo_v2.friend.repository;

import com.gamegoo.gamegoo_v2.friend.domain.Friend;
import org.springframework.data.domain.Slice;

public interface FriendRepositoryCustom {

Slice<Friend> findFriendsByCursorAndOrdered(Long memberId, Long cursor, Integer pageSize);

}
Loading
Loading