diff --git a/.github/workflows/dev_continuous_integration.yml b/.github/workflows/dev_continuous_integration.yml index a0e058298..113f35a1d 100644 --- a/.github/workflows/dev_continuous_integration.yml +++ b/.github/workflows/dev_continuous_integration.yml @@ -1,4 +1,4 @@ -name: Dev CI Pipeline +name: dev ci pipeline on: workflow_dispatch: @@ -32,19 +32,19 @@ jobs: run: | echo "Branch: ${{ github.event.pull_request.base.ref || github.ref_name }}" - - name: Checkout Code (코드 체크아웃) + - name: checkout code uses: actions/checkout@v4 with: submodules: true token: ${{ secrets.GIT_ACCESS_TOKEN }} - - name: Setup JDK (자바 설정) + - name: setup jdk uses: actions/setup-java@v4 with: java-version: '21' distribution: 'temurin' - - name: Setup Gradle Cache (그래들 캐시 설정) + - name: setup gradle cache uses: actions/cache@v3 with: path: ~/.gradle/caches @@ -52,28 +52,29 @@ jobs: restore-keys: | ${{ runner.os }}-gradle- - - name: Install Dependencies (종속성 설치) + - name: install dependencies run: ./gradlew dependencies - - name: Run Integration Tests (통합 테스트 실행) - run: ./gradlew clean integrationTest + - name: run unit tests + run: ./gradlew unit_test - - name: Run Unit Tests (단위 테스트 실행) - run: ./gradlew unitTest + - name: run rules tests + run: ./gradlew check_rule_test - - name: Build Project (프로젝트 빌드) - run: ./gradlew build + - name: run integration tests + run: ./gradlew integration_test - - name: Store Test Results (테스트 결과 저장) + - name: build project + run: ./gradlew build -x test -x asciidoctor --build-cache --parallel + + - name: store test results if: always() uses: actions/upload-artifact@v4 with: name: test-results - path: | - build/reports/tests/ - build/reports/integration-tests/ + path: build/reports/ - - name: Publish Test Report (테스트 보고서 발행) + - name: publish test report uses: mikepenz/action-junit-report@v3 if: always() with: diff --git a/build.gradle b/build.gradle index 99fac61f6..01d09f647 100644 --- a/build.gradle +++ b/build.gradle @@ -206,17 +206,17 @@ tasks.named('test') { excludeTags 'data-jpa-test', 'integration' } } -tasks.register('integrationTest', Test) { +tasks.register('integration_test', Test) { useJUnitPlatform { includeTags 'integration' } } -tasks.register('unitTest', Test) { +tasks.register('unit_test', Test) { useJUnitPlatform { includeTags 'unit' } } -tasks.register('check_rule', Test) { +tasks.register('check_rule_test', Test) { useJUnitPlatform { includeTags 'rule' } diff --git a/git.environment-variables b/git.environment-variables index 2bf643353..793c442a0 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit 2bf6433534696b910c69c8237689ce9753e52f9a +Subproject commit 793c442a01a00ba80b0c39faf132b33a01f92939 diff --git "a/http/block/\354\260\250\353\213\250_\355\225\204\355\204\260\353\247\201_\355\205\214\354\212\244\355\212\270.http" "b/http/block/\354\260\250\353\213\250_\355\225\204\355\204\260\353\247\201_\355\205\214\354\212\244\355\212\270.http" new file mode 100644 index 000000000..575fc8107 --- /dev/null +++ "b/http/block/\354\260\250\353\213\250_\355\225\204\355\204\260\353\247\201_\355\205\214\354\212\244\355\212\270.http" @@ -0,0 +1,38 @@ +### @BlockWord 시리얼라이저 테스트 + +### 토큰 발급 (일반) +# @no-cookie-jar +POST {{host}}/api/v1/oauth/login +Content-Type: application/json + +{ + "email": "rkdtkfma@naver.com", + "socialType": "KAKAO" +} + +> {% + //토큰을 전역변수로 저장 + client.global.set("accessToken", response.body.data.accessToken); +%} + + +### 1. 차단 관계 생성 +POST {{host}}/api/v1/blocks/create +Content-Type: application/json +Authorization: Bearer {{accessToken}} + +{ + "blockedUserId": 3 +} + +### 1-1. 차단 목록 조회 (차단이 제대로 되었는지 확인) +GET {{host}}/api/v1/blocks +Authorization: Bearer {{accessToken}} + +### 2. 리뷰 조회 - 차단된 사용자 컨텐츠 필터링 확인 +GET {{host}}/api/v1/reviews/274 +Authorization: Bearer {{accessToken}} + +### 3. 리뷰 상세 조회 - 차단된 사용자 컨텐츠 필터링 확인 +GET {{host}}/api/v1/reviews/detail/1 +Authorization: Bearer {{accessToken}} diff --git a/src/docs/asciidoc/api/support/block/user-block.adoc b/src/docs/asciidoc/api/support/block/user-block.adoc new file mode 100644 index 000000000..382467f3f --- /dev/null +++ b/src/docs/asciidoc/api/support/block/user-block.adoc @@ -0,0 +1,202 @@ +=== 사용자 차단 관리 === + +사용자 간의 차단 관계를 관리하는 기능입니다. +차단된 사용자와의 상호작용을 제한하여 사용자 경험을 보호합니다. + +==== 차단 기능 개요 ==== + +* 특정 사용자를 차단하여 해당 사용자의 콘텐츠나 상호작용을 차단할 수 있습니다 +* 차단된 사용자는 차단한 사용자의 콘텐츠에 접근하거나 상호작용할 수 없습니다 +* 차단 관계는 일방향성이며, 상호 차단도 가능합니다 + +''' + +==== 사용자 차단 ==== + +특정 사용자를 차단합니다. + +[source] +---- +POST /api/v1/blocks +---- + +[discrete] +===== 요청 파라미터 ===== + +include::{snippets}/block-create/request-fields.adoc[] + +[discrete] +===== 응답 파라미터 ===== + +include::{snippets}/block-create/response-fields.adoc[] + +차단 완료 후 현재 사용자가 차단한 모든 사용자 목록을 반환합니다. + +''' + +==== 차단 해제 ==== + +차단한 사용자를 해제합니다. + +[source] +---- +DELETE /api/v1/blocks/{blockedUserId} +---- + +[discrete] +===== 경로 파라미터 ===== + +include::{snippets}/block-delete/path-parameters.adoc[] + +[discrete] +===== 응답 파라미터 ===== + +include::{snippets}/block-delete/response-fields.adoc[] + +차단 해제 후 현재 사용자가 차단한 모든 사용자 목록을 반환합니다. + +''' + +==== 차단 목록 조회 ==== + +현재 사용자가 차단한 사용자들의 상세 정보를 조회합니다. + +[source] +---- +GET /api/v1/blocks +---- + +[discrete] +===== 응답 파라미터 ===== + +include::{snippets}/block-list/response-fields.adoc[] + +각 차단된 사용자의 ID, 이름, 차단 시각 정보를 포함합니다. + +''' + +==== 차단 사용자 ID 목록 조회 ==== + +빠른 차단 여부 확인을 위해 차단된 사용자 ID만 조회합니다. + +[source] +---- +GET /api/v1/blocks/ids +---- + +[discrete] +===== 응답 파라미터 ===== + +include::{snippets}/block-ids/response-fields.adoc[] + +클라이언트에서 빠른 필터링이나 권한 체크에 사용할 수 있습니다. + +''' + +==== 차단 여부 확인 ==== + +특정 사용자가 차단되었는지 확인합니다. + +[source] +---- +GET /api/v1/blocks/check/{targetUserId} +---- + +[discrete] +===== 경로 파라미터 ===== + +include::{snippets}/block-check/path-parameters.adoc[] + +[discrete] +===== 응답 파라미터 ===== + +include::{snippets}/block-check/response-fields.adoc[] + +''' + +==== 상호 차단 여부 확인 ==== + +두 사용자가 서로 차단했는지 확인합니다. + +[source] +---- +GET /api/v1/blocks/mutual-check/{targetUserId} +---- + +[discrete] +===== 경로 파라미터 ===== + +include::{snippets}/block-mutual-check/path-parameters.adoc[] + +[discrete] +===== 응답 파라미터 ===== + +include::{snippets}/block-mutual-check/response-fields.adoc[] + +상호 차단 상태에서는 양쪽 모두 상대방의 콘텐츠에 접근할 수 없습니다. + +''' + +==== 차단 통계 조회 ==== + +===== 나를 차단한 사용자 수 ===== + +현재 사용자를 차단한 다른 사용자들의 수를 조회합니다. + +[source] +---- +GET /api/v1/blocks/stats/blocked-by-count +---- + +[discrete] +====== 응답 파라미터 ====== + +include::{snippets}/block-blocked-by-count/response-fields.adoc[] + +===== 내가 차단한 사용자 수 ===== + +현재 사용자가 차단한 사용자들의 수를 조회합니다. + +[source] +---- +GET /api/v1/blocks/stats/blocking-count +---- + +[discrete] +====== 응답 파라미터 ====== + +include::{snippets}/block-blocking-count/response-fields.adoc[] + +''' + +==== 에러 코드 ==== + +차단 API 요청 시 발생할 수 있는 예외 상황입니다. + +[cols="1,2,3",options="header"] +|=== +| 코드 | 상황 | 설명 + +| 400 +| 자기 자신을 차단하려는 경우 +| 자기 자신은 차단할 수 없습니다. + +| 400 +| 이미 차단된 사용자를 다시 차단하려는 경우 +| 이미 차단된 사용자입니다. + +| 404 +| 존재하지 않는 사용자를 차단하려는 경우 +| 해당 사용자를 찾을 수 없습니다. + +| 404 +| 차단하지 않은 사용자를 해제하려는 경우 +| 차단 관계가 존재하지 않습니다. + +| 401 +| 인증되지 않은 사용자 +| 로그인이 필요합니다. +|=== + +** 추가되지 않은 에러 코드는 요청 바랍니다. +** Request validation에 대한 내용은 공통 예외를 통해 처리됩니다. \ No newline at end of file diff --git a/src/docs/asciidoc/index.adoc b/src/docs/asciidoc/index.adoc index b206b06c9..2a6220c7c 100644 --- a/src/docs/asciidoc/index.adoc +++ b/src/docs/asciidoc/index.adoc @@ -176,6 +176,8 @@ include::api/review/reply/sub-review-reply-list.adoc[] == 지원 (support) 관련 API +include::api/support/block/user-block.adoc[] + include::api/support/report/user-report.adoc[] include::api/support/help/help-register.adoc[] diff --git a/src/main/java/app/bottlenote/common/block/annotation/BlockWord.java b/src/main/java/app/bottlenote/common/block/annotation/BlockWord.java new file mode 100644 index 000000000..682027774 --- /dev/null +++ b/src/main/java/app/bottlenote/common/block/annotation/BlockWord.java @@ -0,0 +1,33 @@ +package app.bottlenote.common.block.annotation; + +import com.fasterxml.jackson.annotation.JacksonAnnotationsInside; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 차단된 사용자의 컨텐츠를 대체 메시지로 변경하는 어노테이션 + * JSON 직렬화 시점에서 동작합니다. + */ +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +@JacksonAnnotationsInside +@JsonSerialize(using = app.bottlenote.common.block.serializer.BlockWordSerializer.class) +public @interface BlockWord { + + /** + * 차단된 사용자 컨텐츠 대체 메시지 + * 기본값: "차단된 사용자의 글입니다" + */ + String value() default "차단된 사용자의 글입니다"; + + /** + * 차단 판단 기준이 되는 사용자 ID 경로 + * 기본값: "userId" + * 중첩 객체 예시: "userInfo.userId" + */ + String userIdPath() default "userId"; +} diff --git a/src/main/java/app/bottlenote/common/block/config/BlockWordConfig.java b/src/main/java/app/bottlenote/common/block/config/BlockWordConfig.java new file mode 100644 index 000000000..3352343be --- /dev/null +++ b/src/main/java/app/bottlenote/common/block/config/BlockWordConfig.java @@ -0,0 +1,11 @@ +package app.bottlenote.common.block.config; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Configuration; + +@Slf4j +@Configuration +@RequiredArgsConstructor +public class BlockWordConfig { +} diff --git a/src/main/java/app/bottlenote/common/block/serializer/BlockWordSerializer.java b/src/main/java/app/bottlenote/common/block/serializer/BlockWordSerializer.java new file mode 100644 index 000000000..93b24a481 --- /dev/null +++ b/src/main/java/app/bottlenote/common/block/serializer/BlockWordSerializer.java @@ -0,0 +1,191 @@ +package app.bottlenote.common.block.serializer; + +import app.bottlenote.common.block.annotation.BlockWord; +import app.bottlenote.global.security.SecurityContextUtil; +import app.bottlenote.support.block.service.BlockService; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.BeanProperty; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.ser.ContextualSerializer; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.BeansException; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.lang.reflect.Field; + +/** + * @BlockWord 어노테이션이 적용된 필드의 Jackson 커스텀 시리얼라이저 + * 차단된 사용자의 컨텐츠를 대체 메시지로 변경합니다. + */ +@Slf4j +@Component +public class BlockWordSerializer extends JsonSerializer implements ContextualSerializer, ApplicationContextAware { + + private static ApplicationContext applicationContext; + private final BlockService blockService; + private BlockWord blockWordAnnotation; + + public BlockWordSerializer(BlockService blockService) { + this.blockService = blockService; + } + + public BlockWordSerializer() { + this.blockService = null; + } + + @Override + public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { + BlockWordSerializer.applicationContext = applicationContext; + } + + @Override + public void serialize(String value, JsonGenerator gen, SerializerProvider serializers) throws IOException { + if (blockWordAnnotation == null) { + // 어노테이션이 없는 경우 원본 값 반환 + gen.writeString(value); + return; + } + + // BlockService 획득 (의존성 주입된 것이 있으면 사용, 없으면 ApplicationContext에서 가져오기) + BlockService currentBlockService = blockService; + if (currentBlockService == null && applicationContext != null) { + try { + currentBlockService = applicationContext.getBean(BlockService.class); + } catch (Exception e) { + log.warn("ApplicationContext에서 BlockService를 가져오는데 실패: {}", e.getMessage()); + } + } + + if (currentBlockService == null) { + // BlockService를 찾을 수 없는 경우 원본 값 반환 (테스트 환경 등) + gen.writeString(value); + return; + } + + try { + // 현재 사용자 ID 획득 + Long currentUserId = SecurityContextUtil.getUserIdByContext().orElse(null); + if (currentUserId == null) { + // 비로그인 사용자는 필터링 스킵 + gen.writeString(value); + return; + } + + // 현재 직렬화 중인 객체 획득 + Object currentObject = gen.getCurrentValue(); + if (currentObject == null) { + gen.writeString(value); + return; + } + + // userIdPath로 작성자 ID 추출 + Long authorId = extractUserIdByPath(currentObject, blockWordAnnotation.userIdPath()); + if (authorId == null) { + gen.writeString(value); + return; + } + + // 차단 관계 확인 + boolean isBlocked = currentBlockService.isBlocked(currentUserId, authorId); + if (isBlocked) { + // 차단된 경우 대체 메시지로 변경 + gen.writeString(blockWordAnnotation.value()); + } else { + // 정상인 경우 원본 값 사용 + gen.writeString(value); + } + + } catch (Exception e) { + log.warn("BlockWordSerializer 처리 중 오류 발생. 원본 값 반환: {}", e.getMessage()); + gen.writeString(value); + } + } + + @Override + public JsonSerializer createContextual(SerializerProvider prov, BeanProperty property) + throws JsonMappingException { + + if (property != null) { + // @BlockWord 어노테이션 찾기 + BlockWord annotation = property.getAnnotation(BlockWord.class); + if (annotation == null && property.getMember() != null) { + annotation = property.getMember().getAnnotation(BlockWord.class); + } + + if (annotation != null) { + // ApplicationContext에서 BlockService 가져오기 + BlockService contextBlockService = null; + if (applicationContext != null) { + try { + contextBlockService = applicationContext.getBean(BlockService.class); + } catch (Exception e) { + log.warn("ApplicationContext에서 BlockService 가져오기 실패: {}", e.getMessage()); + } + } + + BlockWordSerializer serializer = new BlockWordSerializer(contextBlockService); + serializer.blockWordAnnotation = annotation; + return serializer; + } + } + + return this; + } + + /** + * 지정된 경로로 객체에서 userId 추출 + * + * @param object 대상 객체 + * @param userIdPath userId 경로 (예: "userId", "userInfo.userId") + * @return 추출된 userId, 실패시 null + */ + private Long extractUserIdByPath(Object object, String userIdPath) { + try { + String[] pathParts = userIdPath.split("\\."); + Object currentObject = object; + + // 경로를 따라 객체 탐색 + for (String part : pathParts) { + if (currentObject == null) { + return null; + } + + Field field = findField(currentObject.getClass(), part); + if (field == null) { + return null; + } + + field.setAccessible(true); + currentObject = field.get(currentObject); + } + + return currentObject instanceof Long ? (Long) currentObject : null; + + } catch (Exception e) { + log.warn("userId 추출 중 오류 발생: {}", e.getMessage()); + return null; + } + } + + /** + * 클래스에서 필드 찾기 (상속 계층 포함) + */ + private Field findField(Class clazz, String fieldName) { + Class currentClass = clazz; + + while (currentClass != null) { + try { + return currentClass.getDeclaredField(fieldName); + } catch (NoSuchFieldException e) { + currentClass = currentClass.getSuperclass(); + } + } + + return null; + } +} diff --git a/src/main/java/app/bottlenote/global/cache/local/LocalCacheType.java b/src/main/java/app/bottlenote/global/cache/local/LocalCacheType.java index cf34f65c5..9ddf011a1 100644 --- a/src/main/java/app/bottlenote/global/cache/local/LocalCacheType.java +++ b/src/main/java/app/bottlenote/global/cache/local/LocalCacheType.java @@ -9,7 +9,8 @@ @AllArgsConstructor public enum LocalCacheType { LOCAL_REGION_CACHE("local_cache_alcohol_region_information", 60 * 60 * 24, 1), - LOCAL_ALCOHOL_CATEGORY_CACHE("local_cache_alcohol_category_information", 60 * 60 * 24, 1); + LOCAL_ALCOHOL_CATEGORY_CACHE("local_cache_alcohol_category_information", 60 * 60 * 24, 1), + BLOCKED_USERS_CACHE("blocked_users", 60 * 60 * 2, 1000); private final String cacheName; // 등록할 캐시 이름 private final int secsToExpireAfterWrite; // 캐시 만료 시간 ( 60 * 60 * 24 = 1일 ) diff --git a/src/main/java/app/bottlenote/global/security/SecurityContextUtil.java b/src/main/java/app/bottlenote/global/security/SecurityContextUtil.java index 6ffec6cd1..c416a3d30 100644 --- a/src/main/java/app/bottlenote/global/security/SecurityContextUtil.java +++ b/src/main/java/app/bottlenote/global/security/SecurityContextUtil.java @@ -46,6 +46,18 @@ public static Optional getUserIdByContext() { } Object principal = authentication.getPrincipal(); + + // 테스트 환경에서 TestingAuthenticationToken 지원 + if (authentication.getClass().getSimpleName().equals("TestingAuthenticationToken")) { + try { + Long userId = Long.parseLong(principal.toString()); + log.debug("테스트 환경에서 사용자 ID 추출: {}", userId); + return Optional.of(userId); + } catch (NumberFormatException e) { + log.warn("테스트 환경에서 사용자 ID 파싱 실패: {}", principal); + return Optional.empty(); + } + } if (!(principal instanceof CustomUserContext customUserContext)) { log.debug("인증된 사용자의 정보가 CustomUserContext의 인스턴스가 아닙니다. 비회원 사용자일 수 있습니다."); return Optional.empty(); diff --git a/src/main/java/app/bottlenote/review/facade/payload/ReviewInfo.java b/src/main/java/app/bottlenote/review/facade/payload/ReviewInfo.java index e2c80527b..bebc8ef96 100644 --- a/src/main/java/app/bottlenote/review/facade/payload/ReviewInfo.java +++ b/src/main/java/app/bottlenote/review/facade/payload/ReviewInfo.java @@ -1,5 +1,6 @@ package app.bottlenote.review.facade.payload; +import app.bottlenote.common.block.annotation.BlockWord; import app.bottlenote.global.data.serializers.CustomDeserializers.TagListDeserializer; import app.bottlenote.global.data.serializers.CustomSerializers.TagListSerializer; import app.bottlenote.review.constant.ReviewDisplayStatus; @@ -14,37 +15,38 @@ @Builder public record ReviewInfo( - // 기본 리뷰 정보 - Long reviewId, - String reviewContent, - String reviewImageUrl, - LocalDateTime createAt, - Long totalImageCount, - - // 사용자 정보 - UserInfo userInfo, - Boolean isMyReview, - - // 리뷰 상태 및 속성 - ReviewDisplayStatus status, - Boolean isBestReview, - ReviewLocation locationInfo, - SizeType sizeType, - - // 가격 및 평점 정보 - BigDecimal price, - Double rating, - - // 좋아요 및 댓글 정보 - Long likeCount, - Long replyCount, - Boolean isLikedByMe, - Boolean hasReplyByMe, - - // 기타 정보 - Long viewCount, - @JsonSerialize(using = TagListSerializer.class) - @JsonDeserialize(using = TagListDeserializer.class) - String tastingTagList + // 기본 리뷰 정보 + Long reviewId, + @BlockWord(userIdPath = "userInfo.userId") + String reviewContent, + String reviewImageUrl, + LocalDateTime createAt, + Long totalImageCount, + + // 사용자 정보 + UserInfo userInfo, + Boolean isMyReview, + + // 리뷰 상태 및 속성 + ReviewDisplayStatus status, + Boolean isBestReview, + ReviewLocation locationInfo, + SizeType sizeType, + + // 가격 및 평점 정보 + BigDecimal price, + Double rating, + + // 좋아요 및 댓글 정보 + Long likeCount, + Long replyCount, + Boolean isLikedByMe, + Boolean hasReplyByMe, + + // 기타 정보 + Long viewCount, + @JsonSerialize(using = TagListSerializer.class) + @JsonDeserialize(using = TagListDeserializer.class) + String tastingTagList ) { } diff --git a/src/main/java/app/bottlenote/review/facade/payload/UserInfo.java b/src/main/java/app/bottlenote/review/facade/payload/UserInfo.java index a7d4ddf33..2cf4ef9fd 100644 --- a/src/main/java/app/bottlenote/review/facade/payload/UserInfo.java +++ b/src/main/java/app/bottlenote/review/facade/payload/UserInfo.java @@ -1,9 +1,12 @@ package app.bottlenote.review.facade.payload; +import app.bottlenote.common.block.annotation.BlockWord; + public record UserInfo( - Long userId, - String nickName, - String userProfileImage + Long userId, + @BlockWord(value = "차단된 사용자입니다") + String nickName, + String userProfileImage ) { public static UserInfo of(Long userId, String nickName, String userProfileImage) { diff --git a/src/main/java/app/bottlenote/support/block/README.md b/src/main/java/app/bottlenote/support/block/README.md new file mode 100644 index 000000000..1bac86057 --- /dev/null +++ b/src/main/java/app/bottlenote/support/block/README.md @@ -0,0 +1,114 @@ +# Block 기능 - 사용자 차단 시스템 + +## 📋 개요 + +사용자 간 차단/해제 및 차단된 사용자 컨텐츠 자동 필터링을 제공하는 독립적인 도메인 모듈입니다. + +## 🏗️ 구조 + +``` +support/block/ # Block 도메인 +├── controller/BlockController.java # REST API +├── domain/UserBlock.java # 차단 관계 엔티티 +├── exception/ # Block 전용 예외 +├── repository/ # Repository 인터페이스 & 구현체 +└── service/BlockService.java # 비즈니스 로직 + +common/block/ # 공통 컴포넌트 +├── annotation/BlockWord.java # @BlockWord 어노테이션 +├── config/BlockWordConfig.java # Spring 설정 +└── serializer/BlockWordSerializer.java # Jackson 시리얼라이저 +``` + +## 🎯 핵심 기능 + +### 1. 차단 관리 API + +```http +POST /api/v1/blocks/create # 사용자 차단 +DELETE /api/v1/blocks/{blockedUserId} # 차단 해제 +GET /api/v1/blocks # 차단 목록 조회 +``` + +### 2. 자동 컨텐츠 필터링 + +`@BlockWord` 어노테이션을 통해 JSON 직렬화 시점에서 차단된 사용자의 컨텐츠를 자동으로 대체합니다. + +```java +public record ReviewInfo( + @BlockWord(userIdPath = "userInfo.userId") + String reviewContent, // "차단된 사용자의 글입니다" + UserInfo userInfo +) {} + +public record UserInfo( + Long userId, + @BlockWord(value = "차단된 사용자", userIdPath = "userId") + String nickName, // "차단된 사용자" + String userProfileImage +) {} +``` + +**결과:** +```json +// 차단 전 +{ + "reviewContent": "정말 맛있는 위스키입니다!", + "userInfo": { + "nickName": "위스키러버" + } +} + +// 차단 후 +{ + "reviewContent": "차단된 사용자의 글입니다", + "userInfo": { + "nickName": "차단된 사용자" + } +} +``` + +## 🗄️ 데이터베이스 + +### UserBlock 엔티티 +- **차단 관계**: blocker_id ↔ blocked_id +- **유니크 제약**: 중복 차단 방지 +- **인덱스**: 조회 성능 최적화 +- **BaseTimeEntity**: 생성/수정 시간 자동 관리 + +## ⚡ 성능 최적화 + +### 캐싱 +- **차단 목록**: `@Cacheable(value = "blocked_users")` - 2시간 TTL +- **캐시 무효화**: `@CacheEvict` - 차단/해제 시 자동 무효화 + +### Repository 주요 메서드 +```java +Set findBlockedUserIdsByBlockerId(Long blockerId); // 차단 목록 +boolean existsByBlockerIdAndBlockedId(Long blockerId, Long blockedId); // 차단 여부 +boolean existsMutualBlock(Long userId1, Long userId2); // 상호 차단 +long countByBlockerId(Long blockerId); // 차단한 수 +long countByBlockedId(Long blockedId); // 차단당한 수 +``` + +## 🧪 테스트 + +### HTTP 테스트 파일 +``` +http/block/차단_필터링_테스트.http +``` + +### 기본 테스트 시나리오 +1. 토큰 발급 → 사용자 차단 → 차단 목록 조회 → 컨텐츠 필터링 확인 → 차단 해제 + +## 🔧 동작 원리 + +### 차단 생성 +1. API 호출 → 유효성 검증 → 중복 확인 → DB 저장 → 캐시 무효화 + +### 컨텐츠 필터링 +1. JSON 직렬화 → @BlockWord 감지 → 현재 사용자 확인 → 차단 여부 확인 → 대체 메시지/원본 반환 + +--- + +**핵심**: 간단한 어노테이션 하나로 전체 시스템에서 차단된 사용자 컨텐츠가 자동으로 필터링됩니다. \ No newline at end of file diff --git a/src/main/java/app/bottlenote/support/block/controller/BlockController.java b/src/main/java/app/bottlenote/support/block/controller/BlockController.java new file mode 100644 index 000000000..d190ad2b5 --- /dev/null +++ b/src/main/java/app/bottlenote/support/block/controller/BlockController.java @@ -0,0 +1,105 @@ +package app.bottlenote.support.block.controller; + +import app.bottlenote.global.data.response.CollectionResponse; +import app.bottlenote.global.data.response.GlobalResponse; +import app.bottlenote.global.security.SecurityContextUtil; +import app.bottlenote.support.block.dto.request.BlockCreateRequest; +import app.bottlenote.support.block.dto.response.UserBlockItem; +import app.bottlenote.support.block.exception.BlockException; +import app.bottlenote.support.block.service.BlockService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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.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.RestController; + +import static app.bottlenote.support.block.exception.BlockExceptionCode.REQUIRED_USER_ID; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/blocks") +public class BlockController { + + private final BlockService blockService; + + @PostMapping + public ResponseEntity createBlock(@RequestBody @Valid BlockCreateRequest request) { + Long currentUserId = SecurityContextUtil.getUserIdByContext() + .orElseThrow(() -> new BlockException(REQUIRED_USER_ID)); + + blockService.blockUser(currentUserId, request.blockedUserId()); + CollectionResponse blockedUsers = blockService.getBlockedUserItems(currentUserId); + + return GlobalResponse.ok(blockedUsers); + } + + @DeleteMapping("/{blockedUserId}") + public ResponseEntity deleteBlock(@PathVariable Long blockedUserId) { + Long currentUserId = SecurityContextUtil.getUserIdByContext() + .orElseThrow(() -> new BlockException(REQUIRED_USER_ID)); + + blockService.unblockUser(currentUserId, blockedUserId); + CollectionResponse blockedUsers = blockService.getBlockedUserItems(currentUserId); + + return GlobalResponse.ok(blockedUsers); + } + + @GetMapping + public ResponseEntity getBlockedUsers() { + Long currentUserId = SecurityContextUtil.getUserIdByContext() + .orElseThrow(() -> new BlockException(REQUIRED_USER_ID)); + + return GlobalResponse.ok(blockService.getBlockedUserItems(currentUserId)); + } + + @GetMapping("/ids") + public ResponseEntity getBlockedUserIds() { + Long currentUserId = SecurityContextUtil.getUserIdByContext() + .orElseThrow(() -> new BlockException(REQUIRED_USER_ID)); + + return GlobalResponse.ok(blockService.getBlockedUserIds(currentUserId)); + } + + @GetMapping("/check/{targetUserId}") + public ResponseEntity checkBlocked(@PathVariable Long targetUserId) { + Long currentUserId = SecurityContextUtil.getUserIdByContext() + .orElseThrow(() -> new BlockException(REQUIRED_USER_ID)); + + boolean isBlocked = blockService.isBlocked(currentUserId, targetUserId); + return GlobalResponse.ok(isBlocked); + } + + @GetMapping("/mutual-check/{targetUserId}") + public ResponseEntity checkMutualBlocked(@PathVariable Long targetUserId) { + Long currentUserId = SecurityContextUtil.getUserIdByContext() + .orElseThrow(() -> new BlockException(REQUIRED_USER_ID)); + + boolean isMutualBlocked = blockService.isMutualBlocked(currentUserId, targetUserId); + return GlobalResponse.ok(isMutualBlocked); + } + + @GetMapping("/stats/blocked-by-count") + public ResponseEntity getBlockedByCount() { + Long currentUserId = SecurityContextUtil.getUserIdByContext() + .orElseThrow(() -> new BlockException(REQUIRED_USER_ID)); + + long count = blockService.getBlockedByCount(currentUserId); + return GlobalResponse.ok(count); + } + + @GetMapping("/stats/blocking-count") + public ResponseEntity getBlockingCount() { + Long currentUserId = SecurityContextUtil.getUserIdByContext() + .orElseThrow(() -> new BlockException(REQUIRED_USER_ID)); + + long count = blockService.getBlockingCount(currentUserId); + return GlobalResponse.ok(count); + } +} diff --git a/src/main/java/app/bottlenote/support/block/domain/UserBlock.java b/src/main/java/app/bottlenote/support/block/domain/UserBlock.java new file mode 100644 index 000000000..e57544dba --- /dev/null +++ b/src/main/java/app/bottlenote/support/block/domain/UserBlock.java @@ -0,0 +1,65 @@ +package app.bottlenote.support.block.domain; + +import app.bottlenote.common.domain.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Comment; + +/** + * 사용자 차단 관계를 나타내는 엔티티 + */ +@Getter +@Entity(name = "userBlock") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + name = "user_block", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_blocker_blocked", + columnNames = {"blocker_id", "blocked_id"} + ) + } +) +public class UserBlock extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Comment("차단 관계 ID") + private Long id; + + @Column(name = "blocker_id", nullable = false) + @Comment("차단한 사용자 ID") + private Long blockerId; + + @Column(name = "blocked_id", nullable = false) + @Comment("차단당한 사용자 ID") + private Long blockedId; + + @Column(name = "block_reason", length = 500) + @Comment("차단 사유") + private String blockReason; + + @Builder + public UserBlock(Long blockerId, Long blockedId, String blockReason) { + this.blockerId = blockerId; + this.blockedId = blockedId; + this.blockReason = blockReason; + } + + public static UserBlock create(Long blockerId, Long blockedId, String reason) { + return UserBlock.builder() + .blockerId(blockerId) + .blockedId(blockedId) + .blockReason(reason) + .build(); + } +} diff --git a/src/main/java/app/bottlenote/support/block/dto/request/BlockCreateRequest.java b/src/main/java/app/bottlenote/support/block/dto/request/BlockCreateRequest.java new file mode 100644 index 000000000..8c2ef5ef9 --- /dev/null +++ b/src/main/java/app/bottlenote/support/block/dto/request/BlockCreateRequest.java @@ -0,0 +1,7 @@ +package app.bottlenote.support.block.dto.request; + +/** + * 차단 요청 DTO + */ +public record BlockCreateRequest(Long blockedUserId) { +} \ No newline at end of file diff --git a/src/main/java/app/bottlenote/support/block/dto/response/UserBlockItem.java b/src/main/java/app/bottlenote/support/block/dto/response/UserBlockItem.java new file mode 100644 index 000000000..e33f05e1a --- /dev/null +++ b/src/main/java/app/bottlenote/support/block/dto/response/UserBlockItem.java @@ -0,0 +1,10 @@ +package app.bottlenote.support.block.dto.response; + +import java.time.LocalDateTime; + +public record UserBlockItem( + Long userId, + String userName, + LocalDateTime blockedAt +) { +} diff --git a/src/main/java/app/bottlenote/support/block/exception/BlockException.java b/src/main/java/app/bottlenote/support/block/exception/BlockException.java new file mode 100644 index 000000000..b2c3c1683 --- /dev/null +++ b/src/main/java/app/bottlenote/support/block/exception/BlockException.java @@ -0,0 +1,10 @@ +package app.bottlenote.support.block.exception; + +import app.bottlenote.global.exception.custom.AbstractCustomException; + +public class BlockException extends AbstractCustomException { + + public BlockException(BlockExceptionCode blockExceptionCode) { + super(blockExceptionCode); + } +} \ No newline at end of file diff --git a/src/main/java/app/bottlenote/support/block/exception/BlockExceptionCode.java b/src/main/java/app/bottlenote/support/block/exception/BlockExceptionCode.java new file mode 100644 index 000000000..d5b500fb5 --- /dev/null +++ b/src/main/java/app/bottlenote/support/block/exception/BlockExceptionCode.java @@ -0,0 +1,29 @@ +package app.bottlenote.support.block.exception; + +import app.bottlenote.global.exception.custom.code.ExceptionCode; +import org.springframework.http.HttpStatus; + +public enum BlockExceptionCode implements ExceptionCode { + USER_ALREADY_BLOCKED(HttpStatus.CONFLICT, "이미 차단된 사용자입니다."), + USER_BLOCK_NOT_FOUND(HttpStatus.NOT_FOUND, "차단 관계를 찾을 수 없습니다."), + CANNOT_BLOCK_SELF(HttpStatus.BAD_REQUEST, "자기 자신을 차단할 수 없습니다."), + REQUIRED_USER_ID(HttpStatus.BAD_REQUEST, "유저 아이디가 필요합니다."); + + private final HttpStatus httpStatus; + private final String message; + + BlockExceptionCode(HttpStatus httpStatus, String message) { + this.httpStatus = httpStatus; + this.message = message; + } + + @Override + public String getMessage() { + return message; + } + + @Override + public HttpStatus getHttpStatus() { + return httpStatus; + } +} \ No newline at end of file diff --git a/src/main/java/app/bottlenote/support/block/repository/JpaUserBlockRepository.java b/src/main/java/app/bottlenote/support/block/repository/JpaUserBlockRepository.java new file mode 100644 index 000000000..0d6db8d36 --- /dev/null +++ b/src/main/java/app/bottlenote/support/block/repository/JpaUserBlockRepository.java @@ -0,0 +1,50 @@ +package app.bottlenote.support.block.repository; + +import app.bottlenote.support.block.domain.UserBlock; +import app.bottlenote.support.block.dto.response.UserBlockItem; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +@Repository +public interface JpaUserBlockRepository extends UserBlockRepository, JpaRepository { + + @Override + @Query(""" + SELECT ub.blockedId FROM userBlock ub WHERE ub.blockerId = :blockerId + """) + Set findBlockedUserIdsByBlockerId(@Param("blockerId") Long blockerId); + + @Override + boolean existsByBlockerIdAndBlockedId(Long blockerId, Long blockedId); + + @Override + Optional findByBlockerIdAndBlockedId(Long blockerId, Long blockedId); + + @Override + long countByBlockedId(Long blockedId); + + @Override + long countByBlockerId(Long blockerId); + + @Override + @Query(""" + SELECT COUNT(ub) = 2 FROM userBlock ub + WHERE (ub.blockerId = :userId1 AND ub.blockedId = :userId2) + OR (ub.blockerId = :userId2 AND ub.blockedId = :userId1) + """) + boolean existsMutualBlock(@Param("userId1") Long userId1, @Param("userId2") Long userId2); + + @Override + @Query(""" + SELECT new app.bottlenote.support.block.dto.response.UserBlockItem(u.id, u.nickName, ub.createAt) + FROM userBlock ub JOIN users u ON ub.blockedId = u.id + WHERE ub.blockerId = :blockerId + """) + List findBlockedUserItemsByBlockerId(@Param("blockerId") Long blockerId); +} diff --git a/src/main/java/app/bottlenote/support/block/repository/UserBlockRepository.java b/src/main/java/app/bottlenote/support/block/repository/UserBlockRepository.java new file mode 100644 index 000000000..7f0f64749 --- /dev/null +++ b/src/main/java/app/bottlenote/support/block/repository/UserBlockRepository.java @@ -0,0 +1,29 @@ +package app.bottlenote.support.block.repository; + +import app.bottlenote.support.block.domain.UserBlock; +import app.bottlenote.support.block.dto.response.UserBlockItem; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +public interface UserBlockRepository { + + UserBlock save(UserBlock userBlock); + + void delete(UserBlock userBlock); + + Set findBlockedUserIdsByBlockerId(Long blockerId); + + boolean existsByBlockerIdAndBlockedId(Long blockerId, Long blockedId); + + Optional findByBlockerIdAndBlockedId(Long blockerId, Long blockedId); + + long countByBlockedId(Long blockedId); + + long countByBlockerId(Long blockerId); + + boolean existsMutualBlock(Long userId1, Long userId2); + + List findBlockedUserItemsByBlockerId(Long blockerId); +} \ No newline at end of file diff --git a/src/main/java/app/bottlenote/support/block/service/BlockService.java b/src/main/java/app/bottlenote/support/block/service/BlockService.java new file mode 100644 index 000000000..67461bc24 --- /dev/null +++ b/src/main/java/app/bottlenote/support/block/service/BlockService.java @@ -0,0 +1,142 @@ +package app.bottlenote.support.block.service; + +import app.bottlenote.global.data.response.CollectionResponse; +import app.bottlenote.support.block.domain.UserBlock; +import app.bottlenote.support.block.dto.response.UserBlockItem; +import app.bottlenote.support.block.exception.BlockException; +import app.bottlenote.support.block.repository.UserBlockRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Set; + +import static app.bottlenote.support.block.exception.BlockExceptionCode.CANNOT_BLOCK_SELF; +import static app.bottlenote.support.block.exception.BlockExceptionCode.REQUIRED_USER_ID; +import static app.bottlenote.support.block.exception.BlockExceptionCode.USER_ALREADY_BLOCKED; +import static app.bottlenote.support.block.exception.BlockExceptionCode.USER_BLOCK_NOT_FOUND; + + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class BlockService { + + private final UserBlockRepository userBlockRepository; + + @Transactional + @CacheEvict(value = "blocked_users", key = "#blockerId") + public void blockUser(Long blockerId, Long blockedId, String reason) { + validateBlockRequest(blockerId, blockedId); + + // 이미 차단된 관계인지 확인 + if (userBlockRepository.existsByBlockerIdAndBlockedId(blockerId, blockedId)) { + throw new BlockException(USER_ALREADY_BLOCKED); + } + + // 차단 관계 생성 + UserBlock userBlock = UserBlock.create(blockerId, blockedId, reason); + userBlockRepository.save(userBlock); + + log.info("사용자 차단 완료 - 차단자: {}, 피차단자: {}", blockerId, blockedId); + } + + @Transactional + public void blockUser(Long blockerId, Long blockedId) { + blockUser(blockerId, blockedId, null); + } + + @Transactional + @CacheEvict(value = "blocked_users", key = "#blockerId") + public void unblockUser(Long blockerId, Long blockedId) { + validateBlockRequest(blockerId, blockedId); + + UserBlock userBlock = userBlockRepository.findByBlockerIdAndBlockedId(blockerId, blockedId) + .orElseThrow(() -> new BlockException(USER_BLOCK_NOT_FOUND)); + + userBlockRepository.delete(userBlock); + + log.info("사용자 차단 해제 완료 - 차단자: {}, 피차단자: {}", blockerId, blockedId); + } + + @Transactional(readOnly = true) + @Cacheable(value = "blocked_users", key = "#userId") + public Set getBlockedUserIds(Long userId) { + if (userId == null) { + return Set.of(); + } + + return userBlockRepository.findBlockedUserIdsByBlockerId(userId); + } + + + @Transactional(readOnly = true) + @Cacheable(value = "blocked_user_items", key = "#userId") + public CollectionResponse getBlockedUserItems(Long userId) { + if (userId == null) { + return CollectionResponse.of(0L, List.of()); + } + + List items = userBlockRepository.findBlockedUserItemsByBlockerId(userId); + long totalCount = items.size(); + + return CollectionResponse.of(totalCount, items); + } + + @Transactional(readOnly = true) + public boolean isBlocked(Long blockerId, Long blockedId) { + if (blockerId == null || blockedId == null) { + return false; + } + + Set blockedUsers = getBlockedUserIds(blockerId); + return blockedUsers.contains(blockedId); + } + + @Transactional(readOnly = true) + public boolean isMutualBlocked(Long userId1, Long userId2) { + if (userId1 == null || userId2 == null) { + return false; + } + + return userBlockRepository.existsMutualBlock(userId1, userId2); + } + + @Transactional(readOnly = true) + public long getBlockedByCount(Long userId) { + if (userId == null) { + return 0L; + } + + return userBlockRepository.countByBlockedId(userId); + } + + @Transactional(readOnly = true) + public long getBlockingCount(Long userId) { + if (userId == null) { + return 0L; + } + + return userBlockRepository.countByBlockerId(userId); + } + + private void validateBlockRequest(Long blockerId, Long blockedId) { + if (blockerId == null) { + throw new BlockException(REQUIRED_USER_ID); + } + + if (blockedId == null) { + throw new BlockException(REQUIRED_USER_ID); + } + + if (blockerId.equals(blockedId)) { + throw new BlockException(CANNOT_BLOCK_SELF); + } + } + +} diff --git a/src/main/resources/application-logging.yml b/src/main/resources/application-logging.yml index 2c4e1dc30..3d844c830 100644 --- a/src/main/resources/application-logging.yml +++ b/src/main/resources/application-logging.yml @@ -12,6 +12,9 @@ logging: level: org.hibernate.SQL: off # SQL 쿼리 로깅 비활성화 org.hibernate.orm.jdbc.bind: off # 바인딩 파라미터 로깅 비활성화 + app.bottlenote.common.block.serializer: debug # BlockWordSerializer 디버그 + app.bottlenote.common.block.service: debug # BlockService 디버그 + app.bottlenote.common.block.config: debug # BlockWordConfig 디버그 root: info --- # 개발 환경 diff --git a/src/test/java/app/bottlenote/review/controller/ReviewControllerTest.java b/src/test/java/app/bottlenote/review/controller/ReviewControllerTest.java index 079626282..1d0186d0d 100644 --- a/src/test/java/app/bottlenote/review/controller/ReviewControllerTest.java +++ b/src/test/java/app/bottlenote/review/controller/ReviewControllerTest.java @@ -24,6 +24,7 @@ import app.bottlenote.review.exception.ReviewException; import app.bottlenote.review.fixture.ReviewObjectFixture; import app.bottlenote.review.service.ReviewService; +import app.bottlenote.support.block.service.BlockService; import app.bottlenote.user.exception.UserExceptionCode; import com.fasterxml.jackson.databind.ObjectMapper; import io.jsonwebtoken.ExpiredJwtException; @@ -97,12 +98,16 @@ class ReviewControllerTest { protected MockMvc mockMvc; @MockBean private ReviewService reviewService; + @MockBean + private BlockService blockService; private MockedStatic mockedSecurityUtil; @BeforeEach void setup() { mockedSecurityUtil = mockStatic(SecurityContextUtil.class); + // BlockService mock 설정 - 차단되지 않은 상태로 설정 + when(blockService.isBlocked(any(), any())).thenReturn(false); } @AfterEach diff --git a/src/test/java/app/bottlenote/support/block/fixture/InMemoryUserBlockRepository.java b/src/test/java/app/bottlenote/support/block/fixture/InMemoryUserBlockRepository.java new file mode 100644 index 000000000..678de7d73 --- /dev/null +++ b/src/test/java/app/bottlenote/support/block/fixture/InMemoryUserBlockRepository.java @@ -0,0 +1,107 @@ +package app.bottlenote.support.block.fixture; + +import app.bottlenote.support.block.domain.UserBlock; +import app.bottlenote.support.block.dto.response.UserBlockItem; +import app.bottlenote.support.block.repository.UserBlockRepository; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +public class InMemoryUserBlockRepository implements UserBlockRepository { + + private final Map userBlocks = new HashMap<>(); + private final Map userNames = new HashMap<>(); + private Long idGenerator = 1L; + + public InMemoryUserBlockRepository() { + userNames.put("1", "테스트유저1"); + userNames.put("2", "테스트유저2"); + userNames.put("3", "테스트유저3"); + userNames.put("4", "테스트유저4"); + userNames.put("5", "테스트유저5"); + } + + @Override + public UserBlock save(UserBlock userBlock) { + Long id = idGenerator++; + ReflectionTestUtils.setField(userBlock, "id", id); + userBlocks.put(id, userBlock); + return userBlock; + } + + @Override + public void delete(UserBlock userBlock) { + userBlocks.remove(userBlock.getId()); + } + + @Override + public Set findBlockedUserIdsByBlockerId(Long blockerId) { + return userBlocks.values().stream() + .filter(block -> block.getBlockerId().equals(blockerId)) + .map(UserBlock::getBlockedId) + .collect(Collectors.toSet()); + } + + @Override + public boolean existsByBlockerIdAndBlockedId(Long blockerId, Long blockedId) { + return userBlocks.values().stream() + .anyMatch(block -> block.getBlockerId().equals(blockerId) + && block.getBlockedId().equals(blockedId)); + } + + @Override + public Optional findByBlockerIdAndBlockedId(Long blockerId, Long blockedId) { + return userBlocks.values().stream() + .filter(block -> block.getBlockerId().equals(blockerId) + && block.getBlockedId().equals(blockedId)) + .findFirst(); + } + + @Override + public long countByBlockedId(Long blockedId) { + return userBlocks.values().stream() + .filter(block -> block.getBlockedId().equals(blockedId)) + .count(); + } + + @Override + public long countByBlockerId(Long blockerId) { + return userBlocks.values().stream() + .filter(block -> block.getBlockerId().equals(blockerId)) + .count(); + } + + @Override + public boolean existsMutualBlock(Long userId1, Long userId2) { + boolean user1BlocksUser2 = existsByBlockerIdAndBlockedId(userId1, userId2); + boolean user2BlocksUser1 = existsByBlockerIdAndBlockedId(userId2, userId1); + return user1BlocksUser2 && user2BlocksUser1; + } + + @Override + public List findBlockedUserItemsByBlockerId(Long blockerId) { + return userBlocks.values().stream() + .filter(block -> block.getBlockerId().equals(blockerId)) + .map(block -> new UserBlockItem( + block.getBlockedId(), + userNames.getOrDefault(block.getBlockedId().toString(), "알 수 없는 사용자"), + LocalDateTime.now() + )) + .collect(Collectors.toList()); + } + + public void addUser(Long userId, String userName) { + userNames.put(userId.toString(), userName); + } + + public void clear() { + userBlocks.clear(); + idGenerator = 1L; + } +} diff --git a/src/test/java/app/bottlenote/support/block/service/BlockServiceTest.java b/src/test/java/app/bottlenote/support/block/service/BlockServiceTest.java new file mode 100644 index 000000000..431bf0dff --- /dev/null +++ b/src/test/java/app/bottlenote/support/block/service/BlockServiceTest.java @@ -0,0 +1,424 @@ +package app.bottlenote.support.block.service; + +import app.bottlenote.global.data.response.CollectionResponse; +import app.bottlenote.support.block.dto.response.UserBlockItem; +import app.bottlenote.support.block.exception.BlockException; +import app.bottlenote.support.block.fixture.InMemoryUserBlockRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.Set; + +import static app.bottlenote.support.block.exception.BlockExceptionCode.CANNOT_BLOCK_SELF; +import static app.bottlenote.support.block.exception.BlockExceptionCode.REQUIRED_USER_ID; +import static app.bottlenote.support.block.exception.BlockExceptionCode.USER_ALREADY_BLOCKED; +import static app.bottlenote.support.block.exception.BlockExceptionCode.USER_BLOCK_NOT_FOUND; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Tag("unit") +@DisplayName("[unit] [service] BlockService") +class BlockServiceTest { + + private BlockService blockService; + private InMemoryUserBlockRepository userBlockRepository; + + @BeforeEach + void setUp() { + userBlockRepository = new InMemoryUserBlockRepository(); + blockService = new BlockService(userBlockRepository); + + userBlockRepository.addUser(1L, "차단자"); + userBlockRepository.addUser(2L, "피차단자1"); + userBlockRepository.addUser(3L, "피차단자2"); + userBlockRepository.addUser(4L, "피차단자3"); + } + + @Nested + @DisplayName("사용자를 차단할 때") + class BlockUser { + + @Test + @DisplayName("정상적으로 차단할 수 있다") + void 정상적으로_차단할_수_있다() { + // given + Long blockerId = 1L; + Long blockedId = 2L; + String reason = "스팸 메시지"; + + // when + blockService.blockUser(blockerId, blockedId, reason); + + // then + assertTrue(userBlockRepository.existsByBlockerIdAndBlockedId(blockerId, blockedId)); + } + + @Test + @DisplayName("사유 없이 차단할 수 있다") + void 사유_없이_차단할_수_있다() { + // given + Long blockerId = 1L; + Long blockedId = 2L; + + // when + blockService.blockUser(blockerId, blockedId); + + // then + assertTrue(userBlockRepository.existsByBlockerIdAndBlockedId(blockerId, blockedId)); + } + + @ParameterizedTest + @NullSource + @DisplayName("차단자 ID가 null이면 예외가 발생한다") + void 차단자_ID가_null이면_예외가_발생한다(Long blockerId) { + // given + Long blockedId = 2L; + + // when & then + BlockException exception = assertThrows(BlockException.class, + () -> blockService.blockUser(blockerId, blockedId)); + assertEquals(REQUIRED_USER_ID, exception.getExceptionCode()); + } + + @ParameterizedTest + @NullSource + @DisplayName("피차단자 ID가 null이면 예외가 발생한다") + void 피차단자_ID가_null이면_예외가_발생한다(Long blockedId) { + // given + Long blockerId = 1L; + + // when & then + BlockException exception = assertThrows(BlockException.class, + () -> blockService.blockUser(blockerId, blockedId)); + assertEquals(REQUIRED_USER_ID, exception.getExceptionCode()); + } + + @Test + @DisplayName("자기 자신을 차단하려고 하면 예외가 발생한다") + void 자기_자신을_차단하려고_하면_예외가_발생한다() { + // given + Long userId = 1L; + + // when & then + BlockException exception = assertThrows(BlockException.class, + () -> blockService.blockUser(userId, userId)); + assertEquals(CANNOT_BLOCK_SELF, exception.getExceptionCode()); + } + + @Test + @DisplayName("이미 차단된 사용자를 다시 차단하려고 하면 예외가 발생한다") + void 이미_차단된_사용자를_다시_차단하려고_하면_예외가_발생한다() { + // given + Long blockerId = 1L; + Long blockedId = 2L; + blockService.blockUser(blockerId, blockedId); + + // when & then + BlockException exception = assertThrows(BlockException.class, + () -> blockService.blockUser(blockerId, blockedId)); + assertEquals(USER_ALREADY_BLOCKED, exception.getExceptionCode()); + } + } + + @Nested + @DisplayName("사용자 차단을 해제할 때") + class UnblockUser { + + @Test + @DisplayName("정상적으로 차단을 해제할 수 있다") + void 정상적으로_차단을_해제할_수_있다() { + // given + Long blockerId = 1L; + Long blockedId = 2L; + blockService.blockUser(blockerId, blockedId); + + // when + blockService.unblockUser(blockerId, blockedId); + + // then + assertFalse(userBlockRepository.existsByBlockerIdAndBlockedId(blockerId, blockedId)); + } + + @Test + @DisplayName("차단하지 않은 사용자를 해제하려고 하면 예외가 발생한다") + void 차단하지_않은_사용자를_해제하려고_하면_예외가_발생한다() { + // given + Long blockerId = 1L; + Long blockedId = 2L; + + // when & then + BlockException exception = assertThrows(BlockException.class, + () -> blockService.unblockUser(blockerId, blockedId)); + assertEquals(USER_BLOCK_NOT_FOUND, exception.getExceptionCode()); + } + + @ParameterizedTest + @NullSource + @DisplayName("차단자 ID가 null이면 예외가 발생한다") + void 차단자_ID가_null이면_예외가_발생한다(Long blockerId) { + // given + Long blockedId = 2L; + + // when & then + BlockException exception = assertThrows(BlockException.class, + () -> blockService.unblockUser(blockerId, blockedId)); + assertEquals(REQUIRED_USER_ID, exception.getExceptionCode()); + } + } + + @Nested + @DisplayName("차단된 사용자 ID 목록을 조회할 때") + class GetBlockedUserIds { + + @Test + @DisplayName("차단한 사용자들의 ID를 조회할 수 있다") + void 차단한_사용자들의_ID를_조회할_수_있다() { + // given + Long blockerId = 1L; + blockService.blockUser(blockerId, 2L); + blockService.blockUser(blockerId, 3L); + + // when + Set blockedUserIds = blockService.getBlockedUserIds(blockerId); + + // then + assertEquals(2, blockedUserIds.size()); + assertTrue(blockedUserIds.contains(2L)); + assertTrue(blockedUserIds.contains(3L)); + } + + @Test + @DisplayName("차단한 사용자가 없으면 빈 Set을 반환한다") + void 차단한_사용자가_없으면_빈_Set을_반환한다() { + // given + Long blockerId = 1L; + + // when + Set blockedUserIds = blockService.getBlockedUserIds(blockerId); + + // then + assertTrue(blockedUserIds.isEmpty()); + } + + @Test + @DisplayName("사용자 ID가 null이면 빈 Set을 반환한다") + void 사용자_ID가_null이면_빈_Set을_반환한다() { + // when + Set blockedUserIds = blockService.getBlockedUserIds(null); + + // then + assertTrue(blockedUserIds.isEmpty()); + } + } + + @Nested + @DisplayName("차단된 사용자 상세 정보를 조회할 때") + class GetBlockedUserItems { + + @Test + @DisplayName("차단한 사용자들의 상세 정보를 조회할 수 있다") + void 차단한_사용자들의_상세_정보를_조회할_수_있다() { + // given + Long blockerId = 1L; + blockService.blockUser(blockerId, 2L); + blockService.blockUser(blockerId, 3L); + + // when + CollectionResponse response = blockService.getBlockedUserItems(blockerId); + + // then + assertEquals(2L, response.getTotalCount()); + assertEquals(2, response.getItems().size()); + + UserBlockItem item1 = response.getItems().stream() + .filter(item -> item.userId().equals(2L)) + .findFirst() + .orElseThrow(); + assertEquals("피차단자1", item1.userName()); + assertNotNull(item1.blockedAt()); + } + + @Test + @DisplayName("차단한 사용자가 없으면 빈 목록을 반환한다") + void 차단한_사용자가_없으면_빈_목록을_반환한다() { + // given + Long blockerId = 1L; + + // when + CollectionResponse response = blockService.getBlockedUserItems(blockerId); + + // then + assertEquals(0L, response.getTotalCount()); + assertTrue(response.getItems().isEmpty()); + } + + @Test + @DisplayName("사용자 ID가 null이면 빈 목록을 반환한다") + void 사용자_ID가_null이면_빈_목록을_반환한다() { + // when + CollectionResponse response = blockService.getBlockedUserItems(null); + + // then + assertEquals(0L, response.getTotalCount()); + assertTrue(response.getItems().isEmpty()); + } + } + + @Nested + @DisplayName("차단 여부를 확인할 때") + class IsBlocked { + + @Test + @DisplayName("차단된 사용자는 true를 반환한다") + void 차단된_사용자는_true를_반환한다() { + // given + Long blockerId = 1L; + Long blockedId = 2L; + blockService.blockUser(blockerId, blockedId); + + // when + boolean isBlocked = blockService.isBlocked(blockerId, blockedId); + + // then + assertTrue(isBlocked); + } + + @Test + @DisplayName("차단되지 않은 사용자는 false를 반환한다") + void 차단되지_않은_사용자는_false를_반환한다() { + // given + Long blockerId = 1L; + Long blockedId = 2L; + + // when + boolean isBlocked = blockService.isBlocked(blockerId, blockedId); + + // then + assertFalse(isBlocked); + } + + @ParameterizedTest + @NullSource + @DisplayName("차단자 ID가 null이면 false를 반환한다") + void 차단자_ID가_null이면_false를_반환한다(Long blockerId) { + // given + Long blockedId = 2L; + + // when + boolean isBlocked = blockService.isBlocked(blockerId, blockedId); + + // then + assertFalse(isBlocked); + } + } + + @Nested + @DisplayName("상호 차단 여부를 확인할 때") + class IsMutualBlocked { + + @Test + @DisplayName("서로 차단한 경우 true를 반환한다") + void 서로_차단한_경우_true를_반환한다() { + // given + Long userId1 = 1L; + Long userId2 = 2L; + blockService.blockUser(userId1, userId2); + blockService.blockUser(userId2, userId1); + + // when + boolean isMutualBlocked = blockService.isMutualBlocked(userId1, userId2); + + // then + assertTrue(isMutualBlocked); + } + + @Test + @DisplayName("한쪽만 차단한 경우 false를 반환한다") + void 한쪽만_차단한_경우_false를_반환한다() { + // given + Long userId1 = 1L; + Long userId2 = 2L; + blockService.blockUser(userId1, userId2); + + // when + boolean isMutualBlocked = blockService.isMutualBlocked(userId1, userId2); + + // then + assertFalse(isMutualBlocked); + } + + @Test + @DisplayName("둘 다 차단하지 않은 경우 false를 반환한다") + void 둘_다_차단하지_않은_경우_false를_반환한다() { + // given + Long userId1 = 1L; + Long userId2 = 2L; + + // when + boolean isMutualBlocked = blockService.isMutualBlocked(userId1, userId2); + + // then + assertFalse(isMutualBlocked); + } + } + + @Nested + @DisplayName("차단 통계를 조회할 때") + class BlockStatistics { + + @Test + @DisplayName("특정 사용자를 차단한 사용자 수를 조회할 수 있다") + void 특정_사용자를_차단한_사용자_수를_조회할_수_있다() { + // given + Long targetUserId = 2L; + blockService.blockUser(1L, targetUserId); + blockService.blockUser(3L, targetUserId); + blockService.blockUser(4L, targetUserId); + + // when + long blockedByCount = blockService.getBlockedByCount(targetUserId); + + // then + assertEquals(3L, blockedByCount); + } + + @Test + @DisplayName("특정 사용자가 차단한 사용자 수를 조회할 수 있다") + void 특정_사용자가_차단한_사용자_수를_조회할_수_있다() { + // given + Long blockerId = 1L; + blockService.blockUser(blockerId, 2L); + blockService.blockUser(blockerId, 3L); + blockService.blockUser(blockerId, 4L); + + // when + long blockingCount = blockService.getBlockingCount(blockerId); + + // then + assertEquals(3L, blockingCount); + } + + @ParameterizedTest + @ValueSource(longs = {999L}) + @NullSource + @DisplayName("존재하지 않는 사용자의 통계는 0을 반환한다") + void 존재하지_않는_사용자의_통계는_0을_반환한다(Long userId) { + // when + long blockedByCount = blockService.getBlockedByCount(userId); + long blockingCount = blockService.getBlockingCount(userId); + + // then + assertEquals(0L, blockedByCount); + assertEquals(0L, blockingCount); + } + } +} diff --git a/src/test/java/app/docs/support/block/RestDocsBlockControllerTest.java b/src/test/java/app/docs/support/block/RestDocsBlockControllerTest.java new file mode 100644 index 000000000..367d24f1a --- /dev/null +++ b/src/test/java/app/docs/support/block/RestDocsBlockControllerTest.java @@ -0,0 +1,317 @@ +package app.docs.support.block; + +import app.bottlenote.global.data.response.CollectionResponse; +import app.bottlenote.global.security.SecurityContextUtil; +import app.bottlenote.support.block.controller.BlockController; +import app.bottlenote.support.block.dto.request.BlockCreateRequest; +import app.bottlenote.support.block.dto.response.UserBlockItem; +import app.bottlenote.support.block.service.BlockService; +import app.docs.AbstractRestDocs; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.springframework.http.MediaType; +import org.springframework.restdocs.payload.JsonFieldType; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@DisplayName("BlockController RestDocs 테스트") +class RestDocsBlockControllerTest extends AbstractRestDocs { + + private final BlockService blockService = mock(BlockService.class); + private MockedStatic mockedSecurityUtil; + + @Override + protected Object initController() { + return new BlockController(blockService); + } + + @BeforeEach + void setup() { + mockedSecurityUtil = mockStatic(SecurityContextUtil.class); + mockedSecurityUtil.when(SecurityContextUtil::getUserIdByContext).thenReturn(Optional.of(1L)); + } + + @AfterEach + void tearDown() { + mockedSecurityUtil.close(); + } + + @DisplayName("[restdocs] 사용자를 차단할 수 있다") + @Test + void createBlockTest() throws Exception { + // given + Long currentUserId = 1L; + BlockCreateRequest request = new BlockCreateRequest(2L); + CollectionResponse response = CollectionResponse.of(1L, List.of( + new UserBlockItem(2L, "차단된사용자", LocalDateTime.now()) + )); + + // when + doNothing().when(blockService).blockUser(currentUserId, 2L); + when(blockService.getBlockedUserItems(currentUserId)).thenReturn(response); + + // then + mockMvc.perform(post("/api/v1/blocks") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andDo(document("block-create", + requestFields( + fieldWithPath("blockedUserId").type(JsonFieldType.NUMBER).description("차단할 사용자 ID") + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("성공 여부").ignored(), + fieldWithPath("code").type(JsonFieldType.STRING).description("응답 코드").ignored(), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록").ignored(), + fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보").ignored(), + fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).description("서버 버전").ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).description("서버 인코딩").ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.ARRAY).description("서버 응답 시간").ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).description("서버 경로 버전").ignored(), + fieldWithPath("data.totalCount").type(JsonFieldType.NUMBER).description("차단된 사용자 총 수"), + fieldWithPath("data.items").type(JsonFieldType.ARRAY).description("차단된 사용자 목록"), + fieldWithPath("data.items[].userId").type(JsonFieldType.NUMBER).description("차단된 사용자 ID"), + fieldWithPath("data.items[].userName").type(JsonFieldType.STRING).description("차단된 사용자 이름"), + fieldWithPath("data.items[].blockedAt").type(JsonFieldType.ARRAY).description("차단된 시각") + ) + )); + } + + @DisplayName("[restdocs] 사용자 차단을 해제할 수 있다") + @Test + void deleteBlockTest() throws Exception { + // given + Long currentUserId = 1L; + Long blockedUserId = 2L; + CollectionResponse response = CollectionResponse.of(0L, List.of()); + + // when + doNothing().when(blockService).unblockUser(currentUserId, blockedUserId); + when(blockService.getBlockedUserItems(currentUserId)).thenReturn(response); + + // then + mockMvc.perform(delete("/api/v1/blocks/{blockedUserId}", blockedUserId)) + .andExpect(status().isOk()) + .andDo(document("block-delete", + pathParameters( + parameterWithName("blockedUserId").description("차단 해제할 사용자 ID") + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("성공 여부").ignored(), + fieldWithPath("code").type(JsonFieldType.STRING).description("응답 코드").ignored(), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록").ignored(), + fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보").ignored(), + fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).description("서버 버전").ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).description("서버 인코딩").ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.ARRAY).description("서버 응답 시간").ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).description("서버 경로 버전").ignored(), + fieldWithPath("data.totalCount").type(JsonFieldType.NUMBER).description("차단된 사용자 총 수"), + fieldWithPath("data.items").type(JsonFieldType.ARRAY).description("차단된 사용자 목록") + ) + )); + } + + @DisplayName("[restdocs] 차단된 사용자 목록을 조회할 수 있다") + @Test + void getBlockedUsersTest() throws Exception { + // given + Long currentUserId = 1L; + CollectionResponse response = CollectionResponse.of(2L, List.of( + new UserBlockItem(2L, "차단된사용자1", LocalDateTime.now()), + new UserBlockItem(3L, "차단된사용자2", LocalDateTime.now()) + )); + + // when + when(blockService.getBlockedUserItems(currentUserId)).thenReturn(response); + + // then + mockMvc.perform(get("/api/v1/blocks")) + .andExpect(status().isOk()) + .andDo(document("block-list", + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("성공 여부").ignored(), + fieldWithPath("code").type(JsonFieldType.STRING).description("응답 코드").ignored(), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록").ignored(), + fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보").ignored(), + fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).description("서버 버전").ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).description("서버 인코딩").ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.ARRAY).description("서버 응답 시간").ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).description("서버 경로 버전").ignored(), + fieldWithPath("data.totalCount").type(JsonFieldType.NUMBER).description("차단된 사용자 총 수"), + fieldWithPath("data.items").type(JsonFieldType.ARRAY).description("차단된 사용자 목록"), + fieldWithPath("data.items[].userId").type(JsonFieldType.NUMBER).description("차단된 사용자 ID"), + fieldWithPath("data.items[].userName").type(JsonFieldType.STRING).description("차단된 사용자 이름"), + fieldWithPath("data.items[].blockedAt").type(JsonFieldType.ARRAY).description("차단된 시각") + ) + )); + } + + @DisplayName("[restdocs] 차단된 사용자 ID 목록을 조회할 수 있다") + @Test + void getBlockedUserIdsTest() throws Exception { + // given + Long currentUserId = 1L; + Set response = Set.of(2L, 3L); + + // when + when(blockService.getBlockedUserIds(currentUserId)).thenReturn(response); + + // then + mockMvc.perform(get("/api/v1/blocks/ids")) + .andExpect(status().isOk()) + .andDo(document("block-ids", + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("성공 여부").ignored(), + fieldWithPath("code").type(JsonFieldType.STRING).description("응답 코드").ignored(), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록").ignored(), + fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보").ignored(), + fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).description("서버 버전").ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).description("서버 인코딩").ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.ARRAY).description("서버 응답 시간").ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).description("서버 경로 버전").ignored(), + fieldWithPath("data").type(JsonFieldType.ARRAY).description("차단된 사용자 ID 목록") + ) + )); + } + + @DisplayName("[restdocs] 특정 사용자 차단 여부를 확인할 수 있다") + @Test + void checkBlockedTest() throws Exception { + // given + Long currentUserId = 1L; + Long targetUserId = 2L; + Boolean response = true; + + // when + when(blockService.isBlocked(currentUserId, targetUserId)).thenReturn(response); + + // then + mockMvc.perform(get("/api/v1/blocks/check/{targetUserId}", targetUserId)) + .andExpect(status().isOk()) + .andDo(document("block-check", + pathParameters( + parameterWithName("targetUserId").description("차단 여부를 확인할 사용자 ID") + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("성공 여부").ignored(), + fieldWithPath("code").type(JsonFieldType.STRING).description("응답 코드").ignored(), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록").ignored(), + fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보").ignored(), + fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).description("서버 버전").ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).description("서버 인코딩").ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.ARRAY).description("서버 응답 시간").ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).description("서버 경로 버전").ignored(), + fieldWithPath("data").type(JsonFieldType.BOOLEAN).description("차단 여부") + ) + )); + } + + @DisplayName("[restdocs] 상호 차단 여부를 확인할 수 있다") + @Test + void checkMutualBlockedTest() throws Exception { + // given + Long currentUserId = 1L; + Long targetUserId = 2L; + Boolean response = false; + + // when + when(blockService.isMutualBlocked(currentUserId, targetUserId)).thenReturn(response); + + // then + mockMvc.perform(get("/api/v1/blocks/mutual-check/{targetUserId}", targetUserId)) + .andExpect(status().isOk()) + .andDo(document("block-mutual-check", + pathParameters( + parameterWithName("targetUserId").description("상호 차단 여부를 확인할 사용자 ID") + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("성공 여부").ignored(), + fieldWithPath("code").type(JsonFieldType.STRING).description("응답 코드").ignored(), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록").ignored(), + fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보").ignored(), + fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).description("서버 버전").ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).description("서버 인코딩").ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.ARRAY).description("서버 응답 시간").ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).description("서버 경로 버전").ignored(), + fieldWithPath("data").type(JsonFieldType.BOOLEAN).description("상호 차단 여부") + ) + )); + } + + @DisplayName("[restdocs] 나를 차단한 사용자 수를 조회할 수 있다") + @Test + void getBlockedByCountTest() throws Exception { + // given + Long currentUserId = 1L; + Long response = 5L; + + // when + when(blockService.getBlockedByCount(currentUserId)).thenReturn(response); + + // then + mockMvc.perform(get("/api/v1/blocks/stats/blocked-by-count")) + .andExpect(status().isOk()) + .andDo(document("block-blocked-by-count", + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("성공 여부").ignored(), + fieldWithPath("code").type(JsonFieldType.STRING).description("응답 코드").ignored(), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록").ignored(), + fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보").ignored(), + fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).description("서버 버전").ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).description("서버 인코딩").ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.ARRAY).description("서버 응답 시간").ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).description("서버 경로 버전").ignored(), + fieldWithPath("data").type(JsonFieldType.NUMBER).description("나를 차단한 사용자 수") + ) + )); + } + + @DisplayName("[restdocs] 내가 차단한 사용자 수를 조회할 수 있다") + @Test + void getBlockingCountTest() throws Exception { + // given + Long currentUserId = 1L; + Long response = 3L; + + // when + when(blockService.getBlockingCount(currentUserId)).thenReturn(response); + + // then + mockMvc.perform(get("/api/v1/blocks/stats/blocking-count")) + .andExpect(status().isOk()) + .andDo(document("block-blocking-count", + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("성공 여부").ignored(), + fieldWithPath("code").type(JsonFieldType.STRING).description("응답 코드").ignored(), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록").ignored(), + fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보").ignored(), + fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).description("서버 버전").ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).description("서버 인코딩").ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.ARRAY).description("서버 응답 시간").ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).description("서버 경로 버전").ignored(), + fieldWithPath("data").type(JsonFieldType.NUMBER).description("내가 차단한 사용자 수") + ) + )); + } +}