diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index ebc736731..b9e12e315 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -72,7 +72,9 @@ jobs: envs: >- DB_PASSWORD,GCS_BUCKET,GCS_BACKUP_BUCKET, KAKAO_CLIENT_ID,KAKAO_CLIENT_SECRET,KAKAO_REDIRECT_URI_PROD,KAKAO_REDIRECT_URI_STAGING,KAKAO_ADMIN_KEY, - JWT_SECRET_KEY,FIREBASE_SERVICE_ACCOUNT_KEY + JWT_SECRET_KEY,FIREBASE_SERVICE_ACCOUNT_KEY,FCM_FAKE_LATENCY_MS, + OTLP_METRICS_ENABLED,OTEL_URL,OTEL_AUTH,OTLP_STEP, + CORS_ALLOWED_ORIGINS_PROD,CORS_ALLOWED_ORIGINS_STAGING,COOKIE_DOMAIN,NGINX_PROD_SERVER_NAME,NGINX_STAGING_SERVER_NAME script: | chmod +x /home/ubuntu/cockple/scripts/deploy.sh bash /home/ubuntu/cockple/scripts/deploy.sh \ @@ -89,3 +91,15 @@ jobs: KAKAO_ADMIN_KEY: ${{ secrets.KAKAO_ADMIN_KEY }} JWT_SECRET_KEY: ${{ secrets.JWT_SECRET_KEY }} FIREBASE_SERVICE_ACCOUNT_KEY: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_KEY }} + FCM_FAKE_LATENCY_MS: ${{ vars.FCM_FAKE_LATENCY_MS }} + # 관측성(OTLP) — URL/토글/step은 Variable, 인증 토큰은 Secret + OTLP_METRICS_ENABLED: ${{ vars.OTLP_METRICS_ENABLED }} + OTEL_URL: ${{ vars.OTEL_URL }} + OTEL_AUTH: ${{ secrets.OTEL_AUTH }} + OTLP_STEP: ${{ vars.OTLP_STEP }} + # 도메인(SSOT) — 비밀 아님 → Variables. 미설정 시 빈 값 → docker-compose 기본값(현재 도메인) 사용(no-op). + CORS_ALLOWED_ORIGINS_PROD: ${{ vars.CORS_ALLOWED_ORIGINS_PROD }} + CORS_ALLOWED_ORIGINS_STAGING: ${{ vars.CORS_ALLOWED_ORIGINS_STAGING }} + COOKIE_DOMAIN: ${{ vars.COOKIE_DOMAIN }} + NGINX_PROD_SERVER_NAME: ${{ vars.NGINX_PROD_SERVER_NAME }} + NGINX_STAGING_SERVER_NAME: ${{ vars.NGINX_STAGING_SERVER_NAME }} diff --git a/.gitignore b/.gitignore index aeff3891d..b87032d31 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,7 @@ src/main/resources/firebase/*.json cockple_dump.sql k6 + +### logs ### +logs/ +/result-bulkhead-*.json diff --git a/AGENTS.md b/AGENTS.md index 833ffa033..127751624 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,8 @@ Nearest `AGENTS.md` wins. Read this file first, then the closest child file for |------|----------|-------| | App bootstrap | `src/main/java/umc/cockple/demo/Application.java` | Enables JPA auditing and caching | | Runtime config | `src/main/resources/application*.yml` | `local` is default; `staging` and `prod` override DB/Redis | -| Security/websocket ingress | `src/main/java/umc/cockple/demo/global/config/` | JWT, CORS, WebSocket handler registration | +| Security ingress | `src/main/java/umc/cockple/demo/global/config/` | HTTP security, JWT filters, CORS, shared runtime config | +| Chat WebSocket ingress | `src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/` | `/ws/chats` handler, auth interceptor, endpoint config | | Business APIs | `src/main/java/umc/cockple/demo/domain/` | Vertical slices by feature | | Exercise hotspot | `src/main/java/umc/cockple/demo/domain/exercise/` | Largest converter/query/test surface | | Chat hotspot | `src/main/java/umc/cockple/demo/domain/chat/` | REST + WebSocket + cache/event flow | @@ -48,13 +49,13 @@ Nearest `AGENTS.md` wins. Read this file first, then the closest child file for ## ANTI-PATTERNS (THIS PROJECT) - Do not edit generated QueryDSL Q-types. -- Do not widen security whitelist or CORS origins casually; both are explicit in `SecurityConfig` and `WebSocketConfig`. +- Do not widen security whitelist or CORS origins casually; HTTP origins are explicit in `SecurityConfig` and chat WebSocket origins are explicit in `ChatWebSocketConfig`. - Do not rely on JPA auto-DDL for schema work; startup uses validation only. - Do not scatter test fixtures inside feature test packages; shared fixtures already live under `src/test/java/umc/cockple/demo/support/fixture/`. - Do not assume everything under `global/` is generic; JWT/OAuth code is coupled to member/auth flows. ## UNIQUE STYLES -- `domain/chat` has extra realtime sublayers: `handler/`, `interceptor/`, `events/`, `service/websocket/`. +- `domain/chat` has extra realtime sublayers: `presentation/websocket/`, `events/`, `service/websocket/`. - `domain/exercise` is the densest slice: large converter, query service, command internals, and the biggest integration tests. - `domain/party` and `domain/notification` use events/notification wiring more than simpler slices like `bookmark` or `terms`. diff --git a/build.gradle b/build.gradle index 97dfd314c..3d82ba611 100644 --- a/build.gradle +++ b/build.gradle @@ -102,6 +102,10 @@ dependencies { // flyway implementation 'org.flywaydb:flyway-core' implementation 'org.flywaydb:flyway-mysql' + + // observability (Actuator + OTLP 메트릭 export) + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'io.micrometer:micrometer-registry-otlp' } tasks.named('test') { diff --git a/docker-compose.yml b/docker-compose.yml index 2a883bb6a..2c46d8315 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -56,6 +56,9 @@ services: environment: JAVA_TOOL_OPTIONS: "-Xms768m -Xmx768m" SPRING_PROFILES_ACTIVE: prod + LOG_PATH: /logs + CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS_PROD:-http://localhost:5173,https://cockple.store,https://www.cockple.store,https://staging.cockple.store,https://cockple-fe.vercel.app,https://cockple-fe.vercel.app/} + COOKIE_DOMAIN: ${COOKIE_DOMAIN:-.cockple.store} DB_PASSWORD: ${DB_PASSWORD} GCS_BUCKET: ${GCS_BUCKET} KAKAO_CLIENT_ID: ${KAKAO_CLIENT_ID} @@ -64,6 +67,8 @@ services: KAKAO_ADMIN_KEY: ${KAKAO_ADMIN_KEY} JWT_SECRET_KEY: ${JWT_SECRET_KEY} FIREBASE_SERVICE_ACCOUNT_KEY: ${FIREBASE_SERVICE_ACCOUNT_KEY} + volumes: + - ./logs/prod:/logs depends_on: mysql: condition: service_healthy @@ -79,6 +84,15 @@ services: environment: JAVA_TOOL_OPTIONS: "-Xms128m -Xmx512m" SPRING_PROFILES_ACTIVE: staging + LOG_PATH: /logs + CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS_STAGING:-http://localhost:5173,https://cockple.store,https://www.cockple.store,https://staging.cockple.store,https://cockple-fe.vercel.app,https://cockple-fe.vercel.app/} + COOKIE_DOMAIN: ${COOKIE_DOMAIN:-.cockple.store} + FCM_FAKE_LATENCY_MS: ${FCM_FAKE_LATENCY_MS:-0} + # 관측성(Observability) — OTLP 메트릭 push (staging 전용). 미설정 시 off. + OTLP_METRICS_ENABLED: ${OTLP_METRICS_ENABLED:-false} + OTEL_URL: ${OTEL_URL:-} + OTEL_AUTH: ${OTEL_AUTH:-} + OTLP_STEP: ${OTLP_STEP:-1m} DB_PASSWORD: ${DB_PASSWORD} GCS_BUCKET: ${GCS_BUCKET} KAKAO_CLIENT_ID: ${KAKAO_CLIENT_ID} @@ -87,6 +101,8 @@ services: KAKAO_ADMIN_KEY: ${KAKAO_ADMIN_KEY} JWT_SECRET_KEY: ${JWT_SECRET_KEY} FIREBASE_SERVICE_ACCOUNT_KEY: ${FIREBASE_SERVICE_ACCOUNT_KEY} + volumes: + - ./logs/staging:/logs depends_on: mysql: condition: service_healthy @@ -101,9 +117,13 @@ services: restart: always ports: - "80:80" + environment: + PROD_SERVER_NAME: ${NGINX_PROD_SERVER_NAME:-api.cockple.store} + STAGING_SERVER_NAME: ${NGINX_STAGING_SERVER_NAME:-staging.cockple.store} + NGINX_ENVSUBST_FILTER: SERVER_NAME volumes: - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro - - ./nginx/conf.d:/etc/nginx/conf.d:ro + - ./nginx/templates:/etc/nginx/templates:ro mem_limit: 64m memswap_limit: 128m diff --git a/nginx/conf.d/prod.conf b/nginx/templates/prod.conf.template similarity index 95% rename from nginx/conf.d/prod.conf rename to nginx/templates/prod.conf.template index 635b2a3cf..867822e36 100644 --- a/nginx/conf.d/prod.conf +++ b/nginx/templates/prod.conf.template @@ -1,6 +1,6 @@ server { listen 80; - server_name api.cockple.store; + server_name ${PROD_SERVER_NAME}; location /ws/ { proxy_pass http://cockple-app:8080; diff --git a/nginx/conf.d/staging.conf b/nginx/templates/staging.conf.template similarity index 95% rename from nginx/conf.d/staging.conf rename to nginx/templates/staging.conf.template index dc016b844..0935e2557 100644 --- a/nginx/conf.d/staging.conf +++ b/nginx/templates/staging.conf.template @@ -1,6 +1,6 @@ server { listen 80; - server_name staging.cockple.store; + server_name ${STAGING_SERVER_NAME}; location /ws/ { proxy_pass http://cockple-app-staging:8080; diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 6e9a5fa70..d627e76be 100644 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -26,10 +26,22 @@ KAKAO_REDIRECT_URI_STAGING=${KAKAO_REDIRECT_URI_STAGING} KAKAO_ADMIN_KEY=${KAKAO_ADMIN_KEY} JWT_SECRET_KEY=${JWT_SECRET_KEY} FIREBASE_SERVICE_ACCOUNT_KEY=${FIREBASE_SERVICE_ACCOUNT_KEY} +FCM_FAKE_LATENCY_MS=${FCM_FAKE_LATENCY_MS:-0} +OTLP_METRICS_ENABLED=${OTLP_METRICS_ENABLED:-false} +OTEL_URL=${OTEL_URL:-} +OTEL_AUTH=${OTEL_AUTH:-} +OTLP_STEP=${OTLP_STEP:-1m} +CORS_ALLOWED_ORIGINS_PROD=${CORS_ALLOWED_ORIGINS_PROD:-} +CORS_ALLOWED_ORIGINS_STAGING=${CORS_ALLOWED_ORIGINS_STAGING:-} +COOKIE_DOMAIN=${COOKIE_DOMAIN:-} +NGINX_PROD_SERVER_NAME=${NGINX_PROD_SERVER_NAME:-} +NGINX_STAGING_SERVER_NAME=${NGINX_STAGING_SERVER_NAME:-} EOF echo "${FIREBASE_SERVICE_ACCOUNT_KEY}" > /home/ubuntu/cockple/firebase-service-account.json +mkdir -p /home/ubuntu/cockple/logs/prod /home/ubuntu/cockple/logs/staging + echo "=== 배포 전 상태 ===" sudo docker ps diff --git a/src/main/java/umc/cockple/demo/domain/bookmark/converter/BookmarkConverter.java b/src/main/java/umc/cockple/demo/domain/bookmark/converter/BookmarkConverter.java index 0d22c4bd5..8973fedb4 100644 --- a/src/main/java/umc/cockple/demo/domain/bookmark/converter/BookmarkConverter.java +++ b/src/main/java/umc/cockple/demo/domain/bookmark/converter/BookmarkConverter.java @@ -20,7 +20,8 @@ public class BookmarkConverter { public GetAllExerciseBookmarksResponseDTO exerciseBookmarkToDTO(ExerciseBookmark bookmark, - boolean includeParty, boolean includeExercise) { + boolean includeParty, boolean includeExercise, + int nowMemberCnt) { Exercise exercise = bookmark.getExercise(); return GetAllExerciseBookmarksResponseDTO.builder() @@ -34,7 +35,7 @@ public GetAllExerciseBookmarksResponseDTO exerciseBookmarkToDTO(ExerciseBookmark .startExerciseTime(exercise.getStartTime()) .endExerciseTime(exercise.getEndTime()) .maxMemberCnt(exercise.getMaxCapacity()) - .nowMemberCnt(exercise.getNowCapacity()) + .nowMemberCnt(nowMemberCnt) .includeParty(includeParty) .includeExercise(includeExercise) .build(); diff --git a/src/main/java/umc/cockple/demo/domain/bookmark/repository/ExerciseBookmarkRepository.java b/src/main/java/umc/cockple/demo/domain/bookmark/repository/ExerciseBookmarkRepository.java index efa3d7fab..32889ab0f 100644 --- a/src/main/java/umc/cockple/demo/domain/bookmark/repository/ExerciseBookmarkRepository.java +++ b/src/main/java/umc/cockple/demo/domain/bookmark/repository/ExerciseBookmarkRepository.java @@ -18,6 +18,19 @@ public interface ExerciseBookmarkRepository extends JpaRepository findAllByMember(Member member); + @Query(""" + SELECT DISTINCT eb + FROM ExerciseBookmark eb + JOIN FETCH eb.exercise e + JOIN FETCH e.party p + LEFT JOIN FETCH e.exerciseAddr + LEFT JOIN FETCH p.partyImg + LEFT JOIN FETCH p.chatRoom + LEFT JOIN FETCH p.levels + WHERE eb.member = :member + """) + List findAllByMemberWithDetails(@Param("member") Member member); + @Query(""" SELECT eb.exercise.id FROM ExerciseBookmark eb diff --git a/src/main/java/umc/cockple/demo/domain/bookmark/repository/PartyBookmarkRepository.java b/src/main/java/umc/cockple/demo/domain/bookmark/repository/PartyBookmarkRepository.java index 75059b2d4..93fc4d373 100644 --- a/src/main/java/umc/cockple/demo/domain/bookmark/repository/PartyBookmarkRepository.java +++ b/src/main/java/umc/cockple/demo/domain/bookmark/repository/PartyBookmarkRepository.java @@ -22,6 +22,9 @@ public interface PartyBookmarkRepository extends JpaRepository getAllExerciseBookmarks(Long mem Member member = findByMemberId(memberId); // 찜한 운동 가져오기 - List bookmarks = exerciseBookmarkRepository.findAllByMember(member); + List bookmarks = exerciseBookmarkRepository.findAllByMemberWithDetails(member); // orderType에 따른 정렬 Comparator comparator = Comparator.comparing(ExerciseBookmark::getCreatedAt); @@ -61,20 +66,25 @@ public List getAllExerciseBookmarks(Long mem List partyIds = bookmarks.stream().map(b -> b.getExercise().getParty().getId()).toList(); List exerciseIds = bookmarks.stream().map(b -> b.getExercise().getId()).toList(); - List myParties = memberPartyRepository.findAllPartyIdsByMemberAndPartyIds(memberId, partyIds); - List myExercises = memberExerciseRepository.findAllExerciseIdsByMemberAndExerciseIds(memberId, exerciseIds); + Set myParties = new HashSet<>(memberPartyRepository.findAllPartyIdsByMemberAndPartyIds(memberId, partyIds)); + Set myExercises = new HashSet<>(memberExerciseRepository.findAllExerciseIdsByMemberAndExerciseIds(memberId, exerciseIds)); + + // exercise 도메인의 공통 참여 인원수 조회 로직 사용 (N+1 제거) + Map nowCntByExerciseId = exerciseParticipantCountLookupService + .getParticipantCountsByExerciseIds(exerciseIds); // bookmark -> dto 변환 return bookmarks.stream() .map(bookmark -> { + Long exerciseId = bookmark.getExercise().getId(); boolean includeParty = myParties.contains(bookmark.getExercise().getParty().getId()); - boolean includeExercise = myExercises.contains(bookmark.getExercise().getId()); - return bookmarkConverter.exerciseBookmarkToDTO(bookmark, includeParty, includeExercise); + boolean includeExercise = myExercises.contains(exerciseId); + int nowMemberCnt = nowCntByExerciseId.getOrDefault(exerciseId, 0); + return bookmarkConverter.exerciseBookmarkToDTO(bookmark, includeParty, includeExercise, nowMemberCnt); }) .toList(); } - public List getAllPartyBookmarks(Long memberId, PartyOrderType orderType) { // 회원 조회하기 Member member = findByMemberId(memberId); diff --git a/src/main/java/umc/cockple/demo/domain/bookmark/service/query/lookup/ExerciseBookmarkLookupService.java b/src/main/java/umc/cockple/demo/domain/bookmark/service/query/lookup/ExerciseBookmarkLookupService.java new file mode 100644 index 000000000..3329ae97b --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/bookmark/service/query/lookup/ExerciseBookmarkLookupService.java @@ -0,0 +1,37 @@ +package umc.cockple.demo.domain.bookmark.service.query.lookup; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import umc.cockple.demo.domain.bookmark.repository.ExerciseBookmarkRepository; + +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ExerciseBookmarkLookupService { + + private final ExerciseBookmarkRepository exerciseBookmarkRepository; + + public Map getBookmarkStatus(Long memberId, List exerciseIds) { + if (exerciseIds.isEmpty()) { + return Collections.emptyMap(); + } + + List bookmarkedExerciseIds = exerciseBookmarkRepository + .findAllExerciseIdsByMemberIdAndExerciseIds(memberId, exerciseIds); + Set bookmarkedExerciseIdSet = new HashSet<>(bookmarkedExerciseIds); + + return exerciseIds.stream() + .collect(Collectors.toMap( + exerciseId -> exerciseId, + bookmarkedExerciseIdSet::contains + )); + } +} diff --git a/src/main/java/umc/cockple/demo/domain/chat/AGENTS.md b/src/main/java/umc/cockple/demo/domain/chat/AGENTS.md index cf075e565..b94dbeb3c 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/AGENTS.md +++ b/src/main/java/umc/cockple/demo/domain/chat/AGENTS.md @@ -8,15 +8,19 @@ Chat mixes REST queries with WebSocket transport, Redis-backed subscription/cach ## WHERE TO LOOK | Task | Location | Notes | |------|----------|-------| -| WebSocket ingress | `handler/ChatWebSocketHandler.java` | handles connect/message/close/error | -| Auth for sockets | `interceptor/` | JWT auth is enforced before handler logic | +| WebSocket ingress | `presentation/websocket/ChatWebSocketHandler.java` | handles connect/message/close/error | +| Auth for sockets | `presentation/websocket/` | JWT auth is enforced before handler logic | +| WebSocket dispatch | `presentation/websocket/ChatWebSocketRequestDispatcher.java` | parses socket payloads and delegates authenticated requests | +| WebSocket commands | `presentation/websocket/ChatWebSocketCommandHandler.java` | handles socket request types and publishes command events | +| WebSocket responses | `presentation/websocket/WebSocketResponseSender.java` | serializes and sends socket responses | +| WebSocket response assembly | `converter/ChatWebSocketResponseAssembler.java` | shapes socket response payloads | | Realtime services | `service/websocket/` | subscription, room list cache, message fanout | | Read/query flows | `service/ChatQueryServiceImpl.java` | room lists, unread counts, history | | Events | `events/` | send/subscription events bridge transport and async handlers | -| DTO conversion | `converter/ChatConverter.java` | shapes REST/socket payloads | +| DTO conversion | `converter/ChatConverter.java` | shapes REST/API and common message payloads | ## CONVENTIONS -- `WebSocketConfig` registers `/ws/chats` and wires the JWT interceptor. +- `presentation/websocket/ChatWebSocketConfig` registers `/ws/chats` and wires the JWT interceptor. - Request `type()` drives socket branching: send, subscribe, unsubscribe, and chat-list variants. - Party chat and direct chat share the slice but differ in display-name/image/read-status logic. - Room list freshness depends on `service/websocket/ChatRoomListCacheService`. diff --git a/src/main/java/umc/cockple/demo/domain/chat/converter/ChatConverter.java b/src/main/java/umc/cockple/demo/domain/chat/converter/ChatConverter.java index fb89a6ee8..49943374f 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/converter/ChatConverter.java +++ b/src/main/java/umc/cockple/demo/domain/chat/converter/ChatConverter.java @@ -1,11 +1,9 @@ package umc.cockple.demo.domain.chat.converter; -import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import umc.cockple.demo.domain.chat.domain.*; import umc.cockple.demo.domain.chat.dto.*; import umc.cockple.demo.domain.chat.enums.MessageType; -import umc.cockple.demo.domain.chat.enums.WebSocketMessageType; import umc.cockple.demo.domain.member.domain.Member; import java.util.Collections; @@ -13,7 +11,6 @@ import java.util.stream.Collectors; @Component -@RequiredArgsConstructor public class ChatConverter { public static final String UNKNOWN_USER_NAME = "알 수 없는 사용자"; @@ -101,6 +98,14 @@ public DirectChatRoomDTO.LastMessageInfo toDirectLastMessageInfo(LastMessageCach .build(); } + public ChatUnreadStatusDTO.Response toUnreadStatusResponse(boolean hasPartyUnread, boolean hasDirectUnread) { + return ChatUnreadStatusDTO.Response.builder() + .hasUnread(hasPartyUnread || hasDirectUnread) + .hasPartyUnread(hasPartyUnread) + .hasDirectUnread(hasDirectUnread) + .build(); + } + // ===개인 채팅방 생성=== public DirectChatRoomCreateDTO.Response toDirectChatRoomCreateDTO(ChatRoom chatRoom, List members, String displayName) { return DirectChatRoomCreateDTO.Response.builder() @@ -120,40 +125,6 @@ public List toMemberInfo(List files, - ChatMessage savedMessage, Member sender, String senderProfileImageUrl, int unreadCount) { - return WebSocketMessageDTO.MessageResponse.builder() - .type(WebSocketMessageType.SEND) - .chatRoomId(chatRoomId) - .messageId(savedMessage.getId()) - .content(content) - .messageType(savedMessage.getType()) - .images(files) - .senderId(sender.getId()) - .senderName(sender.getMemberName()) - .senderProfileImageUrl(senderProfileImageUrl) - .timestamp(savedMessage.getCreatedAt()) - .unreadCount(unreadCount) - .build(); - } - - public WebSocketMessageDTO.MessageResponse toSystemMessageResponse( - Long chatRoomId, String content, ChatMessage savedMessage) { - return WebSocketMessageDTO.MessageResponse.builder() - .type(WebSocketMessageType.SEND) - .chatRoomId(chatRoomId) - .messageId(savedMessage.getId()) - .content(content) - .messageType(savedMessage.getType()) - .senderId(null) - .senderName(SYSTEM_USER_NAME) - .senderProfileImageUrl(null) - .timestamp(savedMessage.getCreatedAt()) - .build(); - } - public ChatRoomDetailDTO.ChatRoomInfo toChatRoomDetailChatRoomInfo( ChatRoom chatRoom, String displayName, diff --git a/src/main/java/umc/cockple/demo/domain/chat/converter/ChatWebSocketResponseAssembler.java b/src/main/java/umc/cockple/demo/domain/chat/converter/ChatWebSocketResponseAssembler.java new file mode 100644 index 000000000..cccd0209f --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/converter/ChatWebSocketResponseAssembler.java @@ -0,0 +1,50 @@ +package umc.cockple.demo.domain.chat.converter; + +import org.springframework.stereotype.Component; +import umc.cockple.demo.domain.chat.domain.ChatMessage; +import umc.cockple.demo.domain.chat.dto.ChatCommonDTO; +import umc.cockple.demo.domain.chat.dto.WebSocketMessageDTO; +import umc.cockple.demo.domain.chat.enums.WebSocketMessageType; +import umc.cockple.demo.domain.member.domain.Member; + +import java.util.List; + +@Component +public class ChatWebSocketResponseAssembler { + + private static final String SYSTEM_USER_NAME = "시스템"; + + public WebSocketMessageDTO.MessageResponse toSendMessageResponse( + Long chatRoomId, String content, + List files, + ChatMessage savedMessage, Member sender, String senderProfileImageUrl, int unreadCount) { + return WebSocketMessageDTO.MessageResponse.builder() + .type(WebSocketMessageType.SEND) + .chatRoomId(chatRoomId) + .messageId(savedMessage.getId()) + .content(content) + .messageType(savedMessage.getType()) + .images(files) + .senderId(sender.getId()) + .senderName(sender.getMemberName()) + .senderProfileImageUrl(senderProfileImageUrl) + .timestamp(savedMessage.getCreatedAt()) + .unreadCount(unreadCount) + .build(); + } + + public WebSocketMessageDTO.MessageResponse toSystemMessageResponse( + Long chatRoomId, String content, ChatMessage savedMessage) { + return WebSocketMessageDTO.MessageResponse.builder() + .type(WebSocketMessageType.SEND) + .chatRoomId(chatRoomId) + .messageId(savedMessage.getId()) + .content(content) + .messageType(savedMessage.getType()) + .senderId(null) + .senderName(SYSTEM_USER_NAME) + .senderProfileImageUrl(null) + .timestamp(savedMessage.getCreatedAt()) + .build(); + } +} diff --git a/src/main/java/umc/cockple/demo/domain/chat/dto/ChatUnreadStatusDTO.java b/src/main/java/umc/cockple/demo/domain/chat/dto/ChatUnreadStatusDTO.java new file mode 100644 index 000000000..e696ad5ea --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/dto/ChatUnreadStatusDTO.java @@ -0,0 +1,14 @@ +package umc.cockple.demo.domain.chat.dto; + +import lombok.Builder; + +public class ChatUnreadStatusDTO { + + @Builder + public record Response( + boolean hasUnread, + boolean hasPartyUnread, + boolean hasDirectUnread + ) { + } +} diff --git a/src/main/java/umc/cockple/demo/domain/chat/dto/WebSocketMessageDTO.java b/src/main/java/umc/cockple/demo/domain/chat/dto/WebSocketMessageDTO.java index d6c30915d..2fa2fe064 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/dto/WebSocketMessageDTO.java +++ b/src/main/java/umc/cockple/demo/domain/chat/dto/WebSocketMessageDTO.java @@ -81,6 +81,16 @@ public record UnreadCountUpdateMessage( ) { } + @Builder + public record UnreadStatusUpdateMessage( + WebSocketMessageType type, + boolean hasUnread, + boolean hasPartyUnread, + boolean hasDirectUnread, + LocalDateTime timestamp + ) { + } + @Builder public record ChatRoomListUpdate( WebSocketMessageType type, diff --git a/src/main/java/umc/cockple/demo/domain/chat/enums/WebSocketMessageType.java b/src/main/java/umc/cockple/demo/domain/chat/enums/WebSocketMessageType.java index 28cafbf2f..ae1cb7d67 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/enums/WebSocketMessageType.java +++ b/src/main/java/umc/cockple/demo/domain/chat/enums/WebSocketMessageType.java @@ -9,5 +9,6 @@ public enum WebSocketMessageType { UNSUBSCRIBE_CHAT_LIST, ERROR, UNREAD_COUNT_UPDATE, + UNREAD_STATUS_UPDATE, CHAT_ROOM_LIST_UPDATE, } diff --git a/src/main/java/umc/cockple/demo/domain/chat/events/ChatEventListener.java b/src/main/java/umc/cockple/demo/domain/chat/events/ChatEventListener.java deleted file mode 100644 index ffd93b0eb..000000000 --- a/src/main/java/umc/cockple/demo/domain/chat/events/ChatEventListener.java +++ /dev/null @@ -1,151 +0,0 @@ -package umc.cockple.demo.domain.chat.events; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.context.event.EventListener; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Component; -import org.springframework.transaction.event.TransactionPhase; -import org.springframework.transaction.event.TransactionalEventListener; -import umc.cockple.demo.domain.chat.dto.WebSocketMessageDTO; -import umc.cockple.demo.domain.chat.dto.WebSocketMessageDTO.ChatRoomListUpdate.LastMessageUpdate; -import umc.cockple.demo.domain.chat.service.websocket.ChatListSubscriptionService; -import umc.cockple.demo.domain.chat.service.websocket.ChatRoomListCacheService; -import umc.cockple.demo.domain.chat.service.websocket.ChatSendService; -import umc.cockple.demo.domain.chat.service.websocket.SubscriptionService; -import umc.cockple.demo.domain.notification.events.ChatNotificationEvent; -import umc.cockple.demo.domain.notification.service.ChatPushNotificationService; -import umc.cockple.demo.domain.party.events.PartyMemberJoinedEvent; - -import java.util.HashMap; -import java.util.Map; - -@Component -@RequiredArgsConstructor -@Slf4j -public class ChatEventListener { - - private final ChatSendService chatSendService; - private final SubscriptionService subscriptionService; - private final ChatRoomListCacheService chatRoomListCacheService; - private final ChatListSubscriptionService chatListSubscriptionService; - private final ChatPushNotificationService chatPushNotificationService; - - - @EventListener - @Async - public void handleChatMessageSend(ChatMessageSendEvent event) { - log.info("메시지 전송 이벤트 처리 - 채팅방: {}, 발신자: {}", - event.chatRoomId(), event.senderId()); - try { - chatSendService - .sendMessage(event.chatRoomId(), event.content(), event.files(), event.senderId()); - } catch (Exception e) { - log.error("메시지 전송 이벤트 처리 중 오류 발생", e); - } - } - - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - @Async - public void handleChatNotification(ChatNotificationEvent event) { - log.info("채팅 알림 이벤트 처리 - 채팅방: {}, 발신자: {}", event.chatRoomId(), event.senderId()); - try { - chatPushNotificationService.sendPush(event); - } catch (Exception e) { - log.error("채팅 알림 이벤트 처리 중 오류 발생 - 채팅방: {}", event.chatRoomId(), e); - } - } - - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) //트랜잭션이 커밋된 후에 실행 - @Async - public void handlePartyMemberChanged(PartyMemberJoinedEvent event) { - switch (event.action()) { - case JOINED -> chatSendService.sendSystemMessage(event.partyId(), - event.memberName() + "님이 모임에 참여하셨습니다."); - case LEFT -> chatSendService.sendSystemMessage(event.partyId(), - event.memberName() + "님이 모임을 떠나셨습니다."); - } - } - - @EventListener - public void handleChatRoomSubscription(ChatRoomSubscriptionEvent event) { - log.info("채팅방 구독 이벤트 처리 - 채팅방: {}, 사용자: {}, 액션: {}", - event.chatRoomId(), event.memberId(), event.action()); - - try { - switch (event.action()) { - case "SUBSCRIBE" -> { - subscriptionService.subscribeToChatRoom(event.chatRoomId(), event.memberId()); - log.info("사용자 {}가 채팅방 {}를 구독했습니다.", event.memberId(), event.chatRoomId()); - } - case "UNSUBSCRIBE" -> { - subscriptionService.unsubscribeToChatRoom(event.chatRoomId(), event.memberId()); - log.info("사용자 {}가 채팅방 {}를 구독해제했습니다.", event.memberId(), event.chatRoomId()); - } - default -> log.warn("알 수 없는 구독 액션: {}", event.action()); - } - } catch (Exception e) { - log.error("채팅방 구독 이벤트 처리 중 오류 발생", e); - } - } - - @EventListener - @Async - public void handleChatListSubscription(ChatListSubscriptionEvent event) { - log.info("채팅방 목록 구독 이벤트 처리 시작 - 멤버: {}, 액션: {}, 채팅방 수: {}", - event.memberId(), event.action(), event.chatRoomIds().size()); - - try { - switch (event.action()) { - case "SUBSCRIBE" -> { - chatListSubscriptionService.subscribeToChatList(event.memberId(), event.chatRoomIds()); - log.info("채팅방 목록 구독 완료 - 멤버: {}, 채팅방 수: {}", event.memberId(), event.chatRoomIds().size()); - } - case "UNSUBSCRIBE" -> { - chatListSubscriptionService.unsubscribeFromChatList(event.memberId(), event.chatRoomIds()); - log.info("채팅방 목록 구독 해제 완료 - 멤버: {}, 채팅방 수: {}", event.memberId(), event.chatRoomIds().size()); - } - default -> log.warn("알 수 없는 채팅방 목록 구독 액션: {}", event.action()); - } - - } catch (Exception e) { - log.error("채팅방 목록 구독 이벤트 처리 중 오류 발생 - 멤버: {}, 액션: {}", - event.memberId(), event.action(), e); - } - } - - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - @Async - public void handleChatRoomListUpdate(ChatRoomListUpdateEvent event) { - log.info("채팅방 목록 업데이트 이벤트 처리 시작 - 채팅방: {}", event.chatRoomId()); - - try { - chatRoomListCacheService.evictLastMessage(event.chatRoomId()); - - Map memberUpdateData = new HashMap<>(); - - LastMessageUpdate lastMessageUpdate = LastMessageUpdate.builder() - .content(event.content()) - .timestamp(event.timestamp()) - .messageType(event.messageType()) - .build(); - - for (Map.Entry entry : event.memberUnreadCounts().entrySet()) { - Long memberId = entry.getKey(); - Integer unreadCount = entry.getValue(); - - memberUpdateData.put(memberId, SubscriptionService.ChatRoomListUpdateData.builder() - .lastMessage(lastMessageUpdate) - .unreadCount(unreadCount) - .build()); - } - - subscriptionService.broadcastChatRoomListUpdateToMembers(event.chatRoomId(), memberUpdateData); - - log.info("채팅방 목록 업데이트 이벤트 처리 완료 - 채팅방: {}", event.chatRoomId()); - - } catch (Exception e) { - log.error("채팅방 목록 업데이트 이벤트 처리 중 오류 발생 - 채팅방: {}", event.chatRoomId(), e); - } - } -} diff --git a/src/main/java/umc/cockple/demo/domain/chat/events/ChatUnreadStatusUpdateEvent.java b/src/main/java/umc/cockple/demo/domain/chat/events/ChatUnreadStatusUpdateEvent.java new file mode 100644 index 000000000..14cbf35df --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/events/ChatUnreadStatusUpdateEvent.java @@ -0,0 +1,11 @@ +package umc.cockple.demo.domain.chat.events; + +import java.util.List; + +public record ChatUnreadStatusUpdateEvent( + List targetMemberIds +) { + public static ChatUnreadStatusUpdateEvent of(List targetMemberIds) { + return new ChatUnreadStatusUpdateEvent(List.copyOf(targetMemberIds)); + } +} diff --git a/src/main/java/umc/cockple/demo/domain/chat/handler/ChatWebSocketHandler.java b/src/main/java/umc/cockple/demo/domain/chat/handler/ChatWebSocketHandler.java deleted file mode 100644 index 759614709..000000000 --- a/src/main/java/umc/cockple/demo/domain/chat/handler/ChatWebSocketHandler.java +++ /dev/null @@ -1,234 +0,0 @@ -package umc.cockple.demo.domain.chat.handler; - -import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.stereotype.Component; -import org.springframework.web.socket.CloseStatus; -import org.springframework.web.socket.TextMessage; -import org.springframework.web.socket.WebSocketSession; -import org.springframework.web.socket.handler.TextWebSocketHandler; -import umc.cockple.demo.domain.chat.dto.MemberConnectionInfo; -import umc.cockple.demo.domain.chat.dto.WebSocketMessageDTO; -import umc.cockple.demo.domain.chat.events.ChatListSubscriptionEvent; -import umc.cockple.demo.domain.chat.events.ChatMessageSendEvent; -import umc.cockple.demo.domain.chat.events.ChatRoomSubscriptionEvent; -import umc.cockple.demo.domain.chat.exception.ChatException; -import umc.cockple.demo.domain.chat.service.ChatValidator; -import umc.cockple.demo.domain.chat.service.websocket.SubscriptionService; -import umc.cockple.demo.domain.chat.service.websocket.WebSocketMessageService; -import umc.cockple.demo.domain.member.service.MemberQueryService; - -@Component -@Slf4j -@RequiredArgsConstructor -public class ChatWebSocketHandler extends TextWebSocketHandler { - - private final SubscriptionService subscriptionService; - private final MemberQueryService memberQueryService; - private final WebSocketMessageService webSocketMessageService; - private final ChatValidator chatValidator; - private final ObjectMapper objectMapper; - private final ApplicationEventPublisher eventPublisher; - - @Override - public void afterConnectionEstablished(WebSocketSession session) throws Exception { - log.info("웹소켓 연결 성공"); - - try { - Long memberId = (Long) session.getAttributes().get("memberId"); - Boolean authenticated = (Boolean) session.getAttributes().get("authenticated"); - - if (memberId != null && Boolean.TRUE.equals(authenticated)) { - MemberConnectionInfo memberInfo = memberQueryService.getMemberConnectionInfo(memberId); - session.getAttributes().put("memberName", memberInfo.memberName()); - - subscriptionService.addSession(memberId, session); - log.info("사용자 연결 완료 - memberId: {}, 세션 ID: {}", memberId, session.getId()); - - webSocketMessageService.sendConnectionSuccessMessage(session, memberInfo); - } else { - log.warn("memberId를 찾을 수 없습니다. 세션을 종료합니다."); - session.close(); - } - } catch (Exception e) { - log.error("WebSocket 연결 처리 중 오류 발생", e); - session.close(); - } - } - - @Override - public void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { - log.info("메시지 수신"); - log.info("메시지: {}", message.getPayload()); - - try { - WebSocketMessageDTO.Request request = objectMapper.readValue( - message.getPayload(), WebSocketMessageDTO.Request.class - ); - - Long memberId = (Long) session.getAttributes().get("memberId"); - if (memberId == null) { - webSocketMessageService.sendErrorMessage(session, "UNAUTHORIZED", "인증되지 않은 사용자입니다."); - return; - } - - log.info("메시지 타입: {}, 채팅방 ID: {}, 사용자 ID: {}", memberId, session.getId(), memberId); - - switch (request.type()) { - case SEND: - handleSendMessage(session, request, memberId); - break; - case SUBSCRIBE: - handleSubscribe(session, request, memberId); - break; - case UNSUBSCRIBE: - handleUnsubscribe(session, request, memberId); - break; - case SUBSCRIBE_CHAT_LIST: - handleSubscribeChatList(session, request, memberId); - break; - case UNSUBSCRIBE_CHAT_LIST: - handleUnsubscribeChatList(session, request, memberId); - break; - default: - webSocketMessageService.sendErrorMessage(session, "UNKNOWN_TYPE", "알 수 없는 메시지 타입입니다:" + request.type()); - } - - } catch (Exception e) { - log.error("메시지 처리 중 에러 발생", e); - webSocketMessageService.sendErrorMessage(session, "PROCESSING_ERROR", "메시지 처리 중 오류가 발생했습니다:" + e.getMessage()); - } - } - - @Override - public void afterConnectionClosed(WebSocketSession session, CloseStatus status){ - Long memberId = (Long) session.getAttributes().get("memberId"); - - log.info("웹소켓 연결 종료"); - log.info("세션 ID: {}, 사용자 ID: {}, 종료 상태: {}", session.getId(), memberId, status); - - if (memberId != null) { - subscriptionService.removeSession(memberId); - log.info("사용자 세션 정리 완료 - memberId: {}", memberId); - } - } - - @Override - public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception { - Long memberId = (Long) session.getAttributes().get("memberId"); - if (isShutdownRelatedError(exception)) { - log.debug("서버 종료 관련 WebSocket 전송 오류 (정상) - 세션: {}, 사용자: {}", session.getId(), memberId); - } else { - log.error("WebSocket 전송 오류 발생 - 세션 ID: {}, 사용자 ID: {}", session.getId(), memberId, exception); - } - } - - // ========== 내부 메서드들 ========== - - private void handleSendMessage(WebSocketSession session, WebSocketMessageDTO.Request request, Long memberId) { - try { - chatValidator.validateSendRequest( - request.chatRoomId(), request.content(), request.images(), memberId); - - ChatMessageSendEvent sendEvent = - ChatMessageSendEvent.create( - request.chatRoomId(), request.content(), request.images(), memberId); - eventPublisher.publishEvent(sendEvent); - - } catch (ChatException e) { - log.warn("메시지 전송 실패 - 채팅방: {}, 멤버: {}, 이유: {}", request.chatRoomId(), memberId, e.getErrorReason().getMessage()); - webSocketMessageService.sendErrorMessage(session, e.getErrorReason().getCode(), e.getErrorReason().getMessage()); - } catch (Exception e) { - log.error("메시지 전송 처리 중 예외 발생", e); - webSocketMessageService.sendErrorMessage(session, "SEND_MESSAGE_ERROR", "메시지 전송 처리 중 오류가 발생했습니다."); - } - } - - private void handleSubscribe(WebSocketSession session, WebSocketMessageDTO.Request request, Long memberId) { - try { - chatValidator.validateSubscriptionRequest(request.chatRoomId(), memberId); - - ChatRoomSubscriptionEvent subscribeEvent = - ChatRoomSubscriptionEvent.subscribe(request.chatRoomId(), memberId); - eventPublisher.publishEvent(subscribeEvent); - - webSocketMessageService.sendSubscriptionMessage(session, request.chatRoomId(), "SUBSCRIBE"); - - } catch (ChatException e) { - log.warn("구독 실패 - 채팅방: {}, 멤버: {}, 이유: {}", request.chatRoomId(), memberId, e.getErrorReason().getMessage()); - webSocketMessageService.sendErrorMessage(session, e.getErrorReason().getCode(), e.getErrorReason().getMessage()); - } catch (Exception e) { - log.error("구독 처리 중 예외 발생", e); - webSocketMessageService.sendErrorMessage(session, "SUBSCRIPTION_ERROR", "구독 처리 중 오류가 발생했습니다."); - } - } - - private void handleUnsubscribe(WebSocketSession session, WebSocketMessageDTO.Request request, Long memberId) { - try { - chatValidator.validateUnsubscriptionRequest(request.chatRoomId(), memberId); - - ChatRoomSubscriptionEvent unsubscribeEvent = - ChatRoomSubscriptionEvent.unsubscribe(request.chatRoomId(), memberId); - eventPublisher.publishEvent(unsubscribeEvent); - - webSocketMessageService.sendSubscriptionMessage(session, request.chatRoomId(), "UNSUBSCRIBE"); - - } catch (ChatException e) { - log.warn("구독 해제 실패 - 채팅방: {}, 멤버: {}, 이유: {}", request.chatRoomId(), memberId, e.getErrorReason().getMessage()); - webSocketMessageService.sendErrorMessage(session, e.getErrorReason().getCode(), e.getErrorReason().getMessage()); - } catch (Exception e) { - log.error("구독 해제 처리 중 예외 발생", e); - webSocketMessageService.sendErrorMessage(session, "UNSUBSCRIPTION_ERROR", "구독 해제 처리 중 오류가 발생했습니다."); - } - } - - private void handleSubscribeChatList(WebSocketSession session, WebSocketMessageDTO.Request request, Long memberId) { - try { - chatValidator.validateChatListSubscriptionRequest(memberId, request.memberRooms()); - - ChatListSubscriptionEvent subscribeEvent = - ChatListSubscriptionEvent.subscribe(memberId, request.memberRooms()); - eventPublisher.publishEvent(subscribeEvent); - - webSocketMessageService.sendChatListSubscriptionMessage(session, request.memberRooms(), "SUBSCRIBE_CHAT_LIST"); - - } catch (ChatException e) { - log.warn("채팅방 목록 구독 검증 실패 - 멤버: {}, 이유: {}", memberId, e.getErrorReason().getMessage()); - webSocketMessageService.sendErrorMessage(session, e.getErrorReason().getCode(), e.getErrorReason().getMessage()); - } catch (Exception e) { - log.error("채팅방 목록 구독 처리 중 예외 발생", e); - webSocketMessageService.sendErrorMessage(session, "SUBSCRIPTION_ERROR", "채팅방 목록 구독 처리 중 오류가 발생했습니다."); - } - } - - private void handleUnsubscribeChatList(WebSocketSession session, WebSocketMessageDTO.Request request, Long memberId) { - try { - chatValidator.validateChatListUnsubscriptionRequest(memberId, request.memberRooms()); - - ChatListSubscriptionEvent unsubscribeEvent = - ChatListSubscriptionEvent.unsubscribe(memberId, request.memberRooms()); - eventPublisher.publishEvent(unsubscribeEvent); - - webSocketMessageService.sendChatListSubscriptionMessage(session, request.memberRooms(), "UNSUBSCRIBE_CHAT_LIST"); - - } catch (ChatException e) { - log.warn("채팅방 목록 구독 해제 검증 실패 - 멤버: {}, 이유: {}", memberId, e.getErrorReason().getMessage()); - webSocketMessageService.sendErrorMessage(session, e.getErrorReason().getCode(), e.getErrorReason().getMessage()); - } catch (Exception e) { - log.error("채팅방 목록 구독 해제 처리 중 예외 발생", e); - webSocketMessageService.sendErrorMessage(session, "UNSUBSCRIPTION_ERROR", "채팅방 목록 구독 해제 처리 중 오류가 발생했습니다."); - } - } - - private boolean isShutdownRelatedError(Throwable exception) { - if (exception == null) return false; - - String message = exception.getMessage(); - return message != null && ( - message.contains("ClosedChannelException") || - message.contains("WebSocket session has been closed") - ); - } -} diff --git a/src/main/java/umc/cockple/demo/domain/chat/controller/ChatController.java b/src/main/java/umc/cockple/demo/domain/chat/presentation/rest/ChatController.java similarity index 92% rename from src/main/java/umc/cockple/demo/domain/chat/controller/ChatController.java rename to src/main/java/umc/cockple/demo/domain/chat/presentation/rest/ChatController.java index 7ce4fd011..29e6bb608 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/controller/ChatController.java +++ b/src/main/java/umc/cockple/demo/domain/chat/presentation/rest/ChatController.java @@ -1,4 +1,4 @@ -package umc.cockple.demo.domain.chat.controller; +package umc.cockple.demo.domain.chat.presentation.rest; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -27,6 +27,15 @@ public class ChatController { private final ChatCommandService chatCommandService; private final ChatFileService chatFileService; + @GetMapping("/unread-status") + @Operation(summary = "채팅 안 읽은 메시지 여부 조회", description = "내비 바와 채팅 탭 표시를 위해 모임/개인 채팅의 안 읽은 메시지 존재 여부를 조회합니다.") + @ApiResponse(responseCode = "200", description = "조회 성공") + public BaseResponse getUnreadStatus() { + Long memberId = SecurityUtil.getCurrentMemberId(); + ChatUnreadStatusDTO.Response response = chatQueryService.getUnreadStatus(memberId); + return BaseResponse.success(CommonSuccessCode.OK, response); + } + @GetMapping(value = "/parties") @Operation(summary = "모임 채팅방 목록 조회", description = "회원이 자신의 모임 채팅방 목록을 조회합니다.") @ApiResponse(responseCode = "200", description = "조회 성공") diff --git a/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketCommandHandler.java b/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketCommandHandler.java new file mode 100644 index 000000000..27fbcaa3f --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketCommandHandler.java @@ -0,0 +1,148 @@ +package umc.cockple.demo.domain.chat.presentation.websocket; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.WebSocketSession; +import umc.cockple.demo.domain.chat.dto.WebSocketMessageDTO; +import umc.cockple.demo.domain.chat.events.ChatListSubscriptionEvent; +import umc.cockple.demo.domain.chat.events.ChatMessageSendEvent; +import umc.cockple.demo.domain.chat.events.ChatRoomSubscriptionEvent; +import umc.cockple.demo.domain.chat.exception.ChatException; +import umc.cockple.demo.domain.chat.service.ChatValidator; + +@Component +@Slf4j +@RequiredArgsConstructor +public class ChatWebSocketCommandHandler { + + private final ChatValidator chatValidator; + private final WebSocketResponseSender webSocketResponseSender; + private final ApplicationEventPublisher eventPublisher; + + public void handle(WebSocketSession session, WebSocketMessageDTO.Request request, Long memberId) { + switch (request.type()) { + case SEND: + handleSendMessage(session, request, memberId); + break; + case SUBSCRIBE: + handleSubscribe(session, request, memberId); + break; + case UNSUBSCRIBE: + handleUnsubscribe(session, request, memberId); + break; + case SUBSCRIBE_CHAT_LIST: + handleSubscribeChatList(session, request, memberId); + break; + case UNSUBSCRIBE_CHAT_LIST: + handleUnsubscribeChatList(session, request, memberId); + break; + default: + webSocketResponseSender.sendErrorMessage(session, "UNKNOWN_TYPE", "알 수 없는 메시지 타입입니다:" + request.type()); + } + } + + private void handleSendMessage(WebSocketSession session, WebSocketMessageDTO.Request request, Long memberId) { + try { + chatValidator.validateSendRequest( + request.chatRoomId(), request.content(), request.images(), memberId); + + ChatMessageSendEvent sendEvent = + ChatMessageSendEvent.create( + request.chatRoomId(), request.content(), request.images(), memberId); + eventPublisher.publishEvent(sendEvent); + + } catch (ChatException e) { + log.warn("메시지 전송 실패 - 채팅방: {}, 멤버: {}, 이유: {}", request.chatRoomId(), memberId, e.getErrorReason().getMessage()); + webSocketResponseSender.sendErrorMessage(session, e.getErrorReason().getCode(), e.getErrorReason().getMessage()); + } catch (Exception e) { + log.error("메시지 전송 처리 중 예외 발생", e); + webSocketResponseSender.sendErrorMessage(session, "SEND_MESSAGE_ERROR", "메시지 전송 처리 중 오류가 발생했습니다."); + } + } + + private void handleSubscribe(WebSocketSession session, WebSocketMessageDTO.Request request, Long memberId) { + try { + chatValidator.validateSubscriptionRequest(request.chatRoomId(), memberId); + + ChatRoomSubscriptionEvent subscribeEvent = + ChatRoomSubscriptionEvent.subscribe(request.chatRoomId(), memberId); + eventPublisher.publishEvent(subscribeEvent); + + // ACK는 요청 검증과 구독 이벤트 접수 성공을 의미한다. + // Redis 구독 저장, 읽음 처리, unread-count 브로드캐스트는 listener가 best-effort로 후속 처리한다. + webSocketResponseSender.sendSubscriptionMessage(session, request.chatRoomId(), "SUBSCRIBE"); + + } catch (ChatException e) { + log.warn("구독 실패 - 채팅방: {}, 멤버: {}, 이유: {}", request.chatRoomId(), memberId, e.getErrorReason().getMessage()); + webSocketResponseSender.sendErrorMessage(session, e.getErrorReason().getCode(), e.getErrorReason().getMessage()); + } catch (Exception e) { + log.error("구독 처리 중 예외 발생", e); + webSocketResponseSender.sendErrorMessage(session, "SUBSCRIPTION_ERROR", "구독 처리 중 오류가 발생했습니다."); + } + } + + private void handleUnsubscribe(WebSocketSession session, WebSocketMessageDTO.Request request, Long memberId) { + try { + chatValidator.validateUnsubscriptionRequest(request.chatRoomId(), memberId); + + ChatRoomSubscriptionEvent unsubscribeEvent = + ChatRoomSubscriptionEvent.unsubscribe(request.chatRoomId(), memberId); + eventPublisher.publishEvent(unsubscribeEvent); + + // ACK는 요청 검증과 구독 해제 이벤트 접수 성공을 의미한다. + // Redis 구독 해제는 listener가 best-effort로 후속 처리하고 실패 시 error log만 남긴다. + webSocketResponseSender.sendSubscriptionMessage(session, request.chatRoomId(), "UNSUBSCRIBE"); + + } catch (ChatException e) { + log.warn("구독 해제 실패 - 채팅방: {}, 멤버: {}, 이유: {}", request.chatRoomId(), memberId, e.getErrorReason().getMessage()); + webSocketResponseSender.sendErrorMessage(session, e.getErrorReason().getCode(), e.getErrorReason().getMessage()); + } catch (Exception e) { + log.error("구독 해제 처리 중 예외 발생", e); + webSocketResponseSender.sendErrorMessage(session, "UNSUBSCRIPTION_ERROR", "구독 해제 처리 중 오류가 발생했습니다."); + } + } + + private void handleSubscribeChatList(WebSocketSession session, WebSocketMessageDTO.Request request, Long memberId) { + try { + chatValidator.validateChatListSubscriptionRequest(memberId, request.memberRooms()); + + ChatListSubscriptionEvent subscribeEvent = + ChatListSubscriptionEvent.subscribe(memberId, request.memberRooms()); + eventPublisher.publishEvent(subscribeEvent); + + // ACK는 요청 검증과 채팅방 목록 구독 이벤트 접수 성공을 의미한다. + // Redis 목록 구독 반영은 비동기 listener가 best-effort로 후속 처리한다. + webSocketResponseSender.sendChatListSubscriptionMessage(session, request.memberRooms(), "SUBSCRIBE_CHAT_LIST"); + + } catch (ChatException e) { + log.warn("채팅방 목록 구독 검증 실패 - 멤버: {}, 이유: {}", memberId, e.getErrorReason().getMessage()); + webSocketResponseSender.sendErrorMessage(session, e.getErrorReason().getCode(), e.getErrorReason().getMessage()); + } catch (Exception e) { + log.error("채팅방 목록 구독 처리 중 예외 발생", e); + webSocketResponseSender.sendErrorMessage(session, "SUBSCRIPTION_ERROR", "채팅방 목록 구독 처리 중 오류가 발생했습니다."); + } + } + + private void handleUnsubscribeChatList(WebSocketSession session, WebSocketMessageDTO.Request request, Long memberId) { + try { + chatValidator.validateChatListUnsubscriptionRequest(memberId, request.memberRooms()); + + ChatListSubscriptionEvent unsubscribeEvent = + ChatListSubscriptionEvent.unsubscribe(memberId, request.memberRooms()); + eventPublisher.publishEvent(unsubscribeEvent); + + // ACK는 요청 검증과 채팅방 목록 구독 해제 이벤트 접수 성공을 의미한다. + // Redis 목록 구독 해제 반영은 비동기 listener가 best-effort로 후속 처리한다. + webSocketResponseSender.sendChatListSubscriptionMessage(session, request.memberRooms(), "UNSUBSCRIBE_CHAT_LIST"); + + } catch (ChatException e) { + log.warn("채팅방 목록 구독 해제 검증 실패 - 멤버: {}, 이유: {}", memberId, e.getErrorReason().getMessage()); + webSocketResponseSender.sendErrorMessage(session, e.getErrorReason().getCode(), e.getErrorReason().getMessage()); + } catch (Exception e) { + log.error("채팅방 목록 구독 해제 처리 중 예외 발생", e); + webSocketResponseSender.sendErrorMessage(session, "UNSUBSCRIPTION_ERROR", "채팅방 목록 구독 해제 처리 중 오류가 발생했습니다."); + } + } +} diff --git a/src/main/java/umc/cockple/demo/global/config/WebSocketConfig.java b/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketConfig.java similarity index 66% rename from src/main/java/umc/cockple/demo/global/config/WebSocketConfig.java rename to src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketConfig.java index 94e99a845..a2b426378 100644 --- a/src/main/java/umc/cockple/demo/global/config/WebSocketConfig.java +++ b/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketConfig.java @@ -1,27 +1,27 @@ -package umc.cockple.demo.global.config; +package umc.cockple.demo.domain.chat.presentation.websocket; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; import org.springframework.web.socket.config.annotation.EnableWebSocket; import org.springframework.web.socket.config.annotation.WebSocketConfigurer; import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; -import umc.cockple.demo.domain.chat.handler.ChatWebSocketHandler; -import umc.cockple.demo.domain.chat.interceptor.JWTWebSocketAuthInterceptor; +import umc.cockple.demo.global.config.WebProperties; @Configuration @EnableWebSocket @RequiredArgsConstructor -public class WebSocketConfig implements WebSocketConfigurer { +public class ChatWebSocketConfig implements WebSocketConfigurer { private final ChatWebSocketHandler chatWebSocketHandler; private final JWTWebSocketAuthInterceptor jwtWebSocketAuthInterceptor; + private final WebProperties webProperties; @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry .addHandler(chatWebSocketHandler, "/ws/chats") .addInterceptors(jwtWebSocketAuthInterceptor) - .setAllowedOrigins("http://localhost:5173", "https://cockple.store", "https://www.cockple.store", "https://cockple-fe.vercel.app/", "https://staging.cockple.store") + .setAllowedOrigins(webProperties.getAllowedOrigins().toArray(new String[0])) .withSockJS(); // 브라우저 호환성 } } diff --git a/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketHandler.java b/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketHandler.java new file mode 100644 index 000000000..99f218010 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketHandler.java @@ -0,0 +1,95 @@ +package umc.cockple.demo.domain.chat.presentation.websocket; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.TextWebSocketHandler; +import umc.cockple.demo.domain.chat.dto.MemberConnectionInfo; +import umc.cockple.demo.domain.chat.presentation.websocket.session.WebSocketSessionRegistry; +import umc.cockple.demo.domain.member.service.MemberQueryService; + +@Component +@Slf4j +@RequiredArgsConstructor +public class ChatWebSocketHandler extends TextWebSocketHandler { + + private final MemberQueryService memberQueryService; + private final WebSocketResponseSender webSocketResponseSender; + private final ChatWebSocketRequestDispatcher requestDispatcher; + private final WebSocketSessionRegistry sessionRegistry; + + @Override + public void afterConnectionEstablished(WebSocketSession session) throws Exception { + try (WebSocketMdcSupport.MdcScope ignored = WebSocketMdcSupport.open(session)) { + log.info("웹소켓 연결 성공"); + + try { + Long memberId = (Long) session.getAttributes().get("memberId"); + Boolean authenticated = (Boolean) session.getAttributes().get("authenticated"); + + if (memberId != null && Boolean.TRUE.equals(authenticated)) { + MemberConnectionInfo memberInfo = memberQueryService.getMemberConnectionInfo(memberId); + session.getAttributes().put("memberName", memberInfo.memberName()); + + sessionRegistry.register(memberId, session); + log.info("사용자 연결 완료 - memberId: {}, 세션 ID: {}", memberId, session.getId()); + + webSocketResponseSender.sendConnectionSuccessMessage(session, memberInfo); + } else { + log.warn("memberId를 찾을 수 없습니다. 세션을 종료합니다."); + session.close(); + } + } catch (Exception e) { + log.error("WebSocket 연결 처리 중 오류 발생", e); + session.close(); + } + } + } + + @Override + public void handleTextMessage(WebSocketSession session, TextMessage message) { + try (WebSocketMdcSupport.MdcScope ignored = WebSocketMdcSupport.open(session)) { + requestDispatcher.dispatch(session, message.getPayload()); + } + } + + @Override + public void afterConnectionClosed(WebSocketSession session, CloseStatus status){ + try (WebSocketMdcSupport.MdcScope ignored = WebSocketMdcSupport.open(session)) { + Long memberId = (Long) session.getAttributes().get("memberId"); + + log.info("웹소켓 연결 종료"); + log.info("세션 ID: {}, 사용자 ID: {}, 종료 상태: {}", session.getId(), memberId, status); + + if (memberId != null) { + sessionRegistry.remove(memberId, session); + log.info("사용자 세션 정리 완료 - memberId: {}", memberId); + } + } + } + + @Override + public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception { + try (WebSocketMdcSupport.MdcScope ignored = WebSocketMdcSupport.open(session)) { + Long memberId = (Long) session.getAttributes().get("memberId"); + if (isShutdownRelatedError(exception)) { + log.debug("서버 종료 관련 WebSocket 전송 오류 (정상) - 세션: {}, 사용자: {}", session.getId(), memberId); + } else { + log.error("WebSocket 전송 오류 발생 - 세션 ID: {}, 사용자 ID: {}", session.getId(), memberId, exception); + } + } + } + + private boolean isShutdownRelatedError(Throwable exception) { + if (exception == null) return false; + + String message = exception.getMessage(); + return message != null && ( + message.contains("ClosedChannelException") || + message.contains("WebSocket session has been closed") + ); + } +} diff --git a/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketRequestDispatcher.java b/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketRequestDispatcher.java new file mode 100644 index 000000000..7aa7af119 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketRequestDispatcher.java @@ -0,0 +1,43 @@ +package umc.cockple.demo.domain.chat.presentation.websocket; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.WebSocketSession; +import umc.cockple.demo.domain.chat.dto.WebSocketMessageDTO; + +@Component +@Slf4j +@RequiredArgsConstructor +public class ChatWebSocketRequestDispatcher { + + private final ObjectMapper objectMapper; + private final WebSocketResponseSender webSocketResponseSender; + private final ChatWebSocketCommandHandler commandHandler; + + public void dispatch(WebSocketSession session, String payload) { + try (WebSocketMdcSupport.MdcScope ignored = WebSocketMdcSupport.open(session)) { + log.debug("메시지 수신 - 세션 ID: {}, payloadSize: {}", session.getId(), payload == null ? 0 : payload.length()); + + try { + WebSocketMessageDTO.Request request = objectMapper.readValue( + payload, WebSocketMessageDTO.Request.class + ); + + Long memberId = (Long) session.getAttributes().get("memberId"); + if (memberId == null) { + webSocketResponseSender.sendErrorMessage(session, "UNAUTHORIZED", "인증되지 않은 사용자입니다."); + return; + } + + log.debug("메시지 타입: {}, 채팅방 ID: {}, 사용자 ID: {}", request.type(), session.getId(), memberId); + commandHandler.handle(session, request, memberId); + + } catch (Exception e) { + log.error("메시지 처리 중 에러 발생", e); + webSocketResponseSender.sendErrorMessage(session, "PROCESSING_ERROR", "메시지 처리 중 오류가 발생했습니다."); + } + } + } +} diff --git a/src/main/java/umc/cockple/demo/domain/chat/interceptor/JWTWebSocketAuthInterceptor.java b/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/JWTWebSocketAuthInterceptor.java similarity index 61% rename from src/main/java/umc/cockple/demo/domain/chat/interceptor/JWTWebSocketAuthInterceptor.java rename to src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/JWTWebSocketAuthInterceptor.java index 306b22648..e71a60d3c 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/interceptor/JWTWebSocketAuthInterceptor.java +++ b/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/JWTWebSocketAuthInterceptor.java @@ -1,4 +1,4 @@ -package umc.cockple.demo.domain.chat.interceptor; +package umc.cockple.demo.domain.chat.presentation.websocket; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -21,31 +21,37 @@ public class JWTWebSocketAuthInterceptor implements HandshakeInterceptor { @Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map attributes) throws Exception { - log.debug("JWT 기반 WebSocket 인증 시작"); + try (WebSocketMdcSupport.MdcScope ignored = WebSocketMdcSupport.open((Long) null)) { + log.debug("JWT 기반 WebSocket 인증 시작"); - try { - String token = extractTokenFromRequest(request); + try { + String token = extractTokenFromRequest(request); - if (isInvalidToken(token)) return false; + if (isInvalidToken(token)) return false; - Long memberId = jwtTokenProvider.getUserId(token); + Long memberId = jwtTokenProvider.getUserId(token); - attributes.put("memberId", memberId); - attributes.put("authenticated", true); + attributes.put("memberId", memberId); + attributes.put("authenticated", true); - log.info("JWT 인증 성공 - memberId: {}", memberId); - return true; - } catch (Exception e) { - log.error("JWT 인증 처리 중 오류 발생", e); - return false; + try (WebSocketMdcSupport.MdcScope memberScope = WebSocketMdcSupport.open(memberId)) { + log.info("JWT 인증 성공 - memberId: {}", memberId); + } + return true; + } catch (Exception e) { + log.error("JWT 인증 처리 중 오류 발생", e); + return false; + } } } @Override public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) { - if (exception != null) { - log.error("JWT HandshakeInterceptor 실행 중 오류", exception); + try (WebSocketMdcSupport.MdcScope ignored = WebSocketMdcSupport.open((Long) null)) { + if (exception != null) { + log.error("JWT HandshakeInterceptor 실행 중 오류", exception); + } } } diff --git a/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/WebSocketMdcSupport.java b/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/WebSocketMdcSupport.java new file mode 100644 index 000000000..61a6952f0 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/WebSocketMdcSupport.java @@ -0,0 +1,76 @@ +package umc.cockple.demo.domain.chat.presentation.websocket; + +import org.slf4j.MDC; +import org.springframework.web.socket.WebSocketSession; +import umc.cockple.demo.global.security.filter.JwtAuthenticationFilter; + +import java.util.Map; + +public final class WebSocketMdcSupport { + + public static final String WS_SESSION_ID = "wsSessionId"; + + private WebSocketMdcSupport() { + } + + public static MdcScope open(WebSocketSession session) { + Map previousContext = MDC.getCopyOfContextMap(); + putSessionMdc(session); + return new MdcScope(previousContext); + } + + public static MdcScope open(Long memberId) { + Map previousContext = MDC.getCopyOfContextMap(); + putMemberId(memberId); + return new MdcScope(previousContext); + } + + private static void putSessionMdc(WebSocketSession session) { + if (session == null) { + MDC.remove(WS_SESSION_ID); + MDC.remove(JwtAuthenticationFilter.MEMBER_ID); + return; + } + + String sessionId = session.getId(); + if (sessionId == null || sessionId.isBlank()) { + MDC.remove(WS_SESSION_ID); + } else { + MDC.put(WS_SESSION_ID, sessionId); + } + + Map attributes = session.getAttributes(); + Object memberId = attributes == null ? null : attributes.get(JwtAuthenticationFilter.MEMBER_ID); + if (memberId == null) { + MDC.remove(JwtAuthenticationFilter.MEMBER_ID); + } else { + MDC.put(JwtAuthenticationFilter.MEMBER_ID, String.valueOf(memberId)); + } + } + + private static void putMemberId(Long memberId) { + if (memberId == null) { + MDC.remove(JwtAuthenticationFilter.MEMBER_ID); + } else { + MDC.put(JwtAuthenticationFilter.MEMBER_ID, String.valueOf(memberId)); + } + MDC.remove(WS_SESSION_ID); + } + + public static final class MdcScope implements AutoCloseable { + private final Map previousContext; + + private MdcScope(Map previousContext) { + this.previousContext = previousContext; + } + + @Override + public void close() { + if (previousContext == null || previousContext.isEmpty()) { + MDC.clear(); + return; + } + MDC.setContextMap(previousContext); + } + } +} diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/websocket/WebSocketMessageService.java b/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/WebSocketResponseSender.java similarity index 83% rename from src/main/java/umc/cockple/demo/domain/chat/service/websocket/WebSocketMessageService.java rename to src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/WebSocketResponseSender.java index 7d5ecd9db..482fcc28b 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/service/websocket/WebSocketMessageService.java +++ b/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/WebSocketResponseSender.java @@ -1,14 +1,15 @@ -package umc.cockple.demo.domain.chat.service.websocket; +package umc.cockple.demo.domain.chat.presentation.websocket; -import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; -import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketSession; import umc.cockple.demo.domain.chat.dto.MemberConnectionInfo; import umc.cockple.demo.domain.chat.dto.WebSocketMessageDTO; import umc.cockple.demo.domain.chat.enums.WebSocketMessageType; +import umc.cockple.demo.domain.chat.presentation.websocket.session.WebSocketSessionMessageSender; +import umc.cockple.demo.domain.chat.service.websocket.session.ChatMessageEncoder; +import umc.cockple.demo.domain.chat.service.websocket.session.EncodedChatMessage; import java.time.LocalDateTime; import java.util.List; @@ -16,9 +17,10 @@ @Service @Slf4j @RequiredArgsConstructor -public class WebSocketMessageService { +public class WebSocketResponseSender { - private final ObjectMapper objectMapper; + private final ChatMessageEncoder messageEncoder; + private final WebSocketSessionMessageSender sessionMessageSender; public void sendConnectionSuccessMessage(WebSocketSession session, MemberConnectionInfo memberInfo) { WebSocketMessageDTO.ConnectionInfo connectionInfo = WebSocketMessageDTO.ConnectionInfo.builder() @@ -96,15 +98,13 @@ public void sendChatListSubscriptionMessage(WebSocketSession session, List sendMessage(session, response); } - private void sendMessage(WebSocketSession session, Object message) { - try { - String messageJson = objectMapper.writeValueAsString(message); - synchronized (session) { - session.sendMessage(new TextMessage(messageJson)); - } - } catch (Exception e) { - log.error("메시지 전송 실패", e); + EncodedChatMessage encodedMessage = messageEncoder.encode(message).orElse(null); + if (encodedMessage == null) { + log.error("WebSocket 응답 메시지 인코딩 실패"); + return; } + + sessionMessageSender.send(session, encodedMessage); } } diff --git a/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/session/JacksonChatMessageEncoder.java b/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/session/JacksonChatMessageEncoder.java new file mode 100644 index 000000000..073b94aa4 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/session/JacksonChatMessageEncoder.java @@ -0,0 +1,28 @@ +package umc.cockple.demo.domain.chat.presentation.websocket.session; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import umc.cockple.demo.domain.chat.service.websocket.session.ChatMessageEncoder; +import umc.cockple.demo.domain.chat.service.websocket.session.EncodedChatMessage; + +import java.util.Optional; + +@Component +@RequiredArgsConstructor +@Slf4j +public class JacksonChatMessageEncoder implements ChatMessageEncoder { + + private final ObjectMapper objectMapper; + + @Override + public Optional encode(Object message) { + try { + return Optional.of(new EncodedChatMessage(objectMapper.writeValueAsString(message))); + } catch (Exception e) { + log.error("WebSocket 메시지 JSON 변환 실패", e); + return Optional.empty(); + } + } +} diff --git a/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/session/WebSocketMessageSender.java b/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/session/WebSocketMessageSender.java new file mode 100644 index 000000000..d668b93d6 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/session/WebSocketMessageSender.java @@ -0,0 +1,34 @@ +package umc.cockple.demo.domain.chat.presentation.websocket.session; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.WebSocketSession; +import umc.cockple.demo.domain.chat.service.websocket.session.ChatMessageSender; +import umc.cockple.demo.domain.chat.service.websocket.session.EncodedChatMessage; + +@Component +@RequiredArgsConstructor +@Slf4j +public class WebSocketMessageSender implements ChatMessageSender { + + private final WebSocketSessionRegistry sessionRegistry; + private final WebSocketSessionMessageSender sessionMessageSender; + + @Override + public boolean send(Long memberId, EncodedChatMessage message) { + WebSocketSession session = sessionRegistry.findOpenSession(memberId).orElse(null); + if (session == null) { + log.debug("WebSocket 전송 대상 세션 없음 - 멤버: {}", memberId); + return false; + } + + if (sessionMessageSender.send(session, message)) { + return true; + } + + log.error("WebSocket 메시지 전송 실패 - 멤버: {}", memberId); + sessionRegistry.remove(memberId, session); + return false; + } +} diff --git a/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/session/WebSocketSessionMessageSender.java b/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/session/WebSocketSessionMessageSender.java new file mode 100644 index 000000000..542508dd9 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/session/WebSocketSessionMessageSender.java @@ -0,0 +1,24 @@ +package umc.cockple.demo.domain.chat.presentation.websocket.session; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import umc.cockple.demo.domain.chat.service.websocket.session.EncodedChatMessage; + +@Component +@Slf4j +public class WebSocketSessionMessageSender { + + public boolean send(WebSocketSession session, EncodedChatMessage message) { + try { + synchronized (session) { + session.sendMessage(new TextMessage(message.payload())); + } + return true; + } catch (Exception e) { + log.error("WebSocket 메시지 전송 실패 - 세션: {}", session.getId(), e); + return false; + } + } +} diff --git a/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/session/WebSocketSessionRegistry.java b/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/session/WebSocketSessionRegistry.java new file mode 100644 index 000000000..732e8a088 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/session/WebSocketSessionRegistry.java @@ -0,0 +1,45 @@ +package umc.cockple.demo.domain.chat.presentation.websocket.session; + +import org.springframework.stereotype.Component; +import org.springframework.web.socket.WebSocketSession; +import umc.cockple.demo.domain.chat.service.websocket.session.ChatSessionRegistry; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +@Component +public class WebSocketSessionRegistry implements ChatSessionRegistry { + + private final Map memberSessions = new ConcurrentHashMap<>(); + + public void register(Long memberId, WebSocketSession session) { + memberSessions.put(memberId, session); + } + + public void remove(Long memberId, WebSocketSession session) { + memberSessions.remove(memberId, session); + } + + public Optional findOpenSession(Long memberId) { + WebSocketSession session = memberSessions.get(memberId); + if (session == null) { + return Optional.empty(); + } + if (!session.isOpen()) { + memberSessions.remove(memberId, session); + return Optional.empty(); + } + + return Optional.of(session); + } + + @Override + public List findOpenMemberIds(Collection memberIds) { + return memberIds.stream() + .filter(memberId -> findOpenSession(memberId).isPresent()) + .toList(); + } +} diff --git a/src/main/java/umc/cockple/demo/domain/chat/repository/ChatRoomMemberRepository.java b/src/main/java/umc/cockple/demo/domain/chat/repository/ChatRoomMemberRepository.java index 7c5febc49..7bc2716e7 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/repository/ChatRoomMemberRepository.java +++ b/src/main/java/umc/cockple/demo/domain/chat/repository/ChatRoomMemberRepository.java @@ -45,6 +45,36 @@ int updateDisplayNameByChatRoomIds( @Param("chatRoomIds") List chatRoomIds, @Param("displayName") String displayName); + /* + * lastReadMessageId는 고빈도 읽음 커서라 bulk JPQL로 갱신한다. + * 이 경로는 JPA auditing(updatedAt)을 갱신하지 않는 것을 의도한다. + */ + @Modifying(flushAutomatically = true) + @Query(""" + UPDATE ChatRoomMember crm + SET crm.lastReadMessageId = :messageId + WHERE crm.chatRoom.id = :chatRoomId + AND crm.member.id = :memberId + AND (crm.lastReadMessageId IS NULL OR crm.lastReadMessageId < :messageId) + """) + int advanceLastReadMessageId( + @Param("chatRoomId") Long chatRoomId, + @Param("memberId") Long memberId, + @Param("messageId") Long messageId); + + @Modifying(flushAutomatically = true) + @Query(""" + UPDATE ChatRoomMember crm + SET crm.lastReadMessageId = :messageId + WHERE crm.chatRoom.id = :chatRoomId + AND crm.member.id IN :memberIds + AND (crm.lastReadMessageId IS NULL OR crm.lastReadMessageId < :messageId) + """) + int advanceLastReadMessageIdForMembers( + @Param("chatRoomId") Long chatRoomId, + @Param("memberIds") List memberIds, + @Param("messageId") Long messageId); + // 채팅방 내 참여자 수 @Query("SELECT COUNT(c) FROM ChatRoomMember c WHERE c.chatRoom.id = :chatRoomId") int countByChatRoomId(@Param("chatRoomId") Long chatRoomId); diff --git a/src/main/java/umc/cockple/demo/domain/chat/repository/MessageReadStatusRepository.java b/src/main/java/umc/cockple/demo/domain/chat/repository/MessageReadStatusRepository.java index 6b133ee6c..8aaa2a1d5 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/repository/MessageReadStatusRepository.java +++ b/src/main/java/umc/cockple/demo/domain/chat/repository/MessageReadStatusRepository.java @@ -5,6 +5,9 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import umc.cockple.demo.domain.chat.domain.MessageReadStatus; +import umc.cockple.demo.domain.chat.repository.projection.ChatMessageUnreadCountDTO; +import umc.cockple.demo.domain.chat.repository.projection.ChatMemberUnreadCountDTO; +import umc.cockple.demo.domain.chat.repository.projection.ChatRoomUnreadCountDTO; import java.util.List; @@ -36,31 +39,110 @@ int markAsReadInMembers( @Param("messageId") Long messageId, @Param("memberIds") List memberIds); + @Modifying(flushAutomatically = true) @Query(""" - SELECT COUNT(mrs) FROM MessageReadStatus mrs + UPDATE MessageReadStatus mrs + SET mrs.isRead = true WHERE mrs.chatRoomId = :chatRoomId AND mrs.memberId = :memberId - AND mrs.chatMessageId > :lastReadMessageId + AND mrs.chatMessageId IN :messageIds AND mrs.isRead = false """) - int countUnreadMessagesAfter( + int markMessagesAsReadForMember( @Param("chatRoomId") Long chatRoomId, @Param("memberId") Long memberId, - @Param("lastReadMessageId") Long lastReadMessageId - ); - + @Param("messageIds") List messageIds); @Query(""" - SELECT COUNT(mrs) FROM MessageReadStatus mrs - WHERE mrs.chatRoomId = :chatRoomId + SELECT new umc.cockple.demo.domain.chat.repository.projection.ChatRoomUnreadCountDTO(mrs.chatRoomId, COUNT(mrs)) + FROM MessageReadStatus mrs, ChatRoomMember crm + WHERE crm.chatRoom.id = mrs.chatRoomId + AND crm.member.id = :memberId AND mrs.memberId = :memberId + AND mrs.chatRoomId IN :chatRoomIds AND mrs.isRead = false + AND (crm.lastReadMessageId IS NULL OR mrs.chatMessageId > crm.lastReadMessageId) + GROUP BY mrs.chatRoomId """) - int countAllUnreadMessages( + List countUnreadMessagesByChatRooms( + @Param("memberId") Long memberId, + @Param("chatRoomIds") List chatRoomIds + ); + + @Query(""" + SELECT new umc.cockple.demo.domain.chat.repository.projection.ChatMemberUnreadCountDTO(mrs.memberId, COUNT(mrs)) + FROM MessageReadStatus mrs, ChatRoomMember crm + WHERE crm.chatRoom.id = mrs.chatRoomId + AND crm.member.id = mrs.memberId + AND mrs.chatRoomId = :chatRoomId + AND mrs.memberId IN :memberIds + AND mrs.isRead = false + AND (crm.lastReadMessageId IS NULL OR mrs.chatMessageId > crm.lastReadMessageId) + GROUP BY mrs.memberId + """) + List countUnreadMessagesByMembers( @Param("chatRoomId") Long chatRoomId, - @Param("memberId") Long memberId + @Param("memberIds") List memberIds ); + @Query(""" + SELECT CASE WHEN COUNT(mrs) > 0 THEN true ELSE false END FROM MessageReadStatus mrs + WHERE mrs.memberId = :memberId + AND mrs.isRead = false + AND EXISTS ( + SELECT 1 FROM ChatRoomMember crm + WHERE crm.chatRoom.id = mrs.chatRoomId + AND crm.member.id = :memberId + AND crm.chatRoom.type = 'PARTY' + AND (crm.lastReadMessageId IS NULL OR mrs.chatMessageId > crm.lastReadMessageId) + ) + """) + boolean existsPartyUnreadMessagesByMemberId(@Param("memberId") Long memberId); + + @Query(""" + SELECT CASE WHEN COUNT(mrs) > 0 THEN true ELSE false END FROM MessageReadStatus mrs + WHERE mrs.memberId = :memberId + AND mrs.isRead = false + AND EXISTS ( + SELECT 1 FROM ChatRoomMember crm + WHERE crm.chatRoom.id = mrs.chatRoomId + AND crm.member.id = :memberId + AND crm.chatRoom.type = 'DIRECT' + AND crm.status = 'JOINED' + AND (crm.lastReadMessageId IS NULL OR mrs.chatMessageId > crm.lastReadMessageId) + ) + """) + boolean existsDirectUnreadMessagesByMemberId(@Param("memberId") Long memberId); + + @Query(""" + SELECT DISTINCT mrs.memberId FROM MessageReadStatus mrs + WHERE mrs.memberId IN :memberIds + AND mrs.isRead = false + AND EXISTS ( + SELECT 1 FROM ChatRoomMember crm + WHERE crm.chatRoom.id = mrs.chatRoomId + AND crm.member.id = mrs.memberId + AND crm.chatRoom.type = 'PARTY' + AND (crm.lastReadMessageId IS NULL OR mrs.chatMessageId > crm.lastReadMessageId) + ) + """) + List findMemberIdsWithPartyUnreadMessages(@Param("memberIds") List memberIds); + + @Query(""" + SELECT DISTINCT mrs.memberId FROM MessageReadStatus mrs + WHERE mrs.memberId IN :memberIds + AND mrs.isRead = false + AND EXISTS ( + SELECT 1 FROM ChatRoomMember crm + WHERE crm.chatRoom.id = mrs.chatRoomId + AND crm.member.id = mrs.memberId + AND crm.chatRoom.type = 'DIRECT' + AND crm.status = 'JOINED' + AND (crm.lastReadMessageId IS NULL OR mrs.chatMessageId > crm.lastReadMessageId) + ) + """) + List findMemberIdsWithDirectUnreadMessages(@Param("memberIds") List memberIds); + @Query(""" SELECT COUNT(mrs) FROM MessageReadStatus mrs WHERE mrs.chatMessageId = :messageId @@ -69,6 +151,16 @@ SELECT COUNT(mrs) FROM MessageReadStatus mrs int countUnreadByMessageId( @Param("messageId") Long messageId); + @Query(""" + SELECT new umc.cockple.demo.domain.chat.repository.projection.ChatMessageUnreadCountDTO(mrs.chatMessageId, COUNT(mrs)) + FROM MessageReadStatus mrs + WHERE mrs.chatMessageId IN :messageIds + AND mrs.isRead = false + GROUP BY mrs.chatMessageId + """) + List countUnreadByMessageIds( + @Param("messageIds") List messageIds); + @Query(""" SELECT mrs.chatMessageId FROM MessageReadStatus mrs WHERE mrs.chatRoomId = :chatRoomId diff --git a/src/main/java/umc/cockple/demo/domain/chat/repository/projection/ChatMemberUnreadCountDTO.java b/src/main/java/umc/cockple/demo/domain/chat/repository/projection/ChatMemberUnreadCountDTO.java new file mode 100644 index 000000000..2fe41c0ed --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/repository/projection/ChatMemberUnreadCountDTO.java @@ -0,0 +1,7 @@ +package umc.cockple.demo.domain.chat.repository.projection; + +public record ChatMemberUnreadCountDTO( + Long memberId, + Long unreadCount +) { +} diff --git a/src/main/java/umc/cockple/demo/domain/chat/repository/projection/ChatMessageUnreadCountDTO.java b/src/main/java/umc/cockple/demo/domain/chat/repository/projection/ChatMessageUnreadCountDTO.java new file mode 100644 index 000000000..70d56d525 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/repository/projection/ChatMessageUnreadCountDTO.java @@ -0,0 +1,7 @@ +package umc.cockple.demo.domain.chat.repository.projection; + +public record ChatMessageUnreadCountDTO( + Long chatMessageId, + Long unreadCount +) { +} diff --git a/src/main/java/umc/cockple/demo/domain/chat/repository/projection/ChatRoomUnreadCountDTO.java b/src/main/java/umc/cockple/demo/domain/chat/repository/projection/ChatRoomUnreadCountDTO.java new file mode 100644 index 000000000..12de51221 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/repository/projection/ChatRoomUnreadCountDTO.java @@ -0,0 +1,7 @@ +package umc.cockple.demo.domain.chat.repository.projection; + +public record ChatRoomUnreadCountDTO( + Long chatRoomId, + Long unreadCount +) { +} diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/websocket/ChatListSubscriptionService.java b/src/main/java/umc/cockple/demo/domain/chat/repository/redis/ChatListSubscriptionStore.java similarity index 94% rename from src/main/java/umc/cockple/demo/domain/chat/service/websocket/ChatListSubscriptionService.java rename to src/main/java/umc/cockple/demo/domain/chat/repository/redis/ChatListSubscriptionStore.java index db14e3630..4d59b9504 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/service/websocket/ChatListSubscriptionService.java +++ b/src/main/java/umc/cockple/demo/domain/chat/repository/redis/ChatListSubscriptionStore.java @@ -1,19 +1,19 @@ -package umc.cockple.demo.domain.chat.service.websocket; +package umc.cockple.demo.domain.chat.repository.redis; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.stereotype.Service; +import org.springframework.stereotype.Component; import java.time.Duration; import java.util.List; import java.util.Set; import java.util.stream.Collectors; -@Service +@Component @RequiredArgsConstructor @Slf4j -public class ChatListSubscriptionService { +public class ChatListSubscriptionStore { private final StringRedisTemplate stringRedisTemplate; diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/websocket/RedisSubscriptionService.java b/src/main/java/umc/cockple/demo/domain/chat/repository/redis/ChatRoomSubscriptionStore.java similarity index 95% rename from src/main/java/umc/cockple/demo/domain/chat/service/websocket/RedisSubscriptionService.java rename to src/main/java/umc/cockple/demo/domain/chat/repository/redis/ChatRoomSubscriptionStore.java index 139ea094f..ecb236931 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/service/websocket/RedisSubscriptionService.java +++ b/src/main/java/umc/cockple/demo/domain/chat/repository/redis/ChatRoomSubscriptionStore.java @@ -1,18 +1,18 @@ -package umc.cockple.demo.domain.chat.service.websocket; +package umc.cockple.demo.domain.chat.repository.redis; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.stereotype.Service; +import org.springframework.stereotype.Component; import java.time.Duration; import java.util.Set; import java.util.stream.Collectors; -@Service +@Component @RequiredArgsConstructor @Slf4j -public class RedisSubscriptionService { +public class ChatRoomSubscriptionStore { private final StringRedisTemplate stringRedisTemplate; diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/ChatQueryService.java b/src/main/java/umc/cockple/demo/domain/chat/service/ChatQueryService.java index 8fa953e40..b88e766dc 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/service/ChatQueryService.java +++ b/src/main/java/umc/cockple/demo/domain/chat/service/ChatQueryService.java @@ -11,6 +11,8 @@ public interface ChatQueryService { DirectChatRoomDTO.Response searchDirectChatRoomsByName(Long memberId, String name, int page, int size); + ChatUnreadStatusDTO.Response getUnreadStatus(Long memberId); + ChatRoomDetailDTO.Response getChatRoomDetail(Long roomId, Long memberId); ChatMessageDTO.Response getChatMessages(Long roomId, Long memberId, Long cursor, int size); diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/ChatQueryServiceImpl.java b/src/main/java/umc/cockple/demo/domain/chat/service/ChatQueryServiceImpl.java index 0c56ecd74..fb2fe73f4 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/service/ChatQueryServiceImpl.java +++ b/src/main/java/umc/cockple/demo/domain/chat/service/ChatQueryServiceImpl.java @@ -18,7 +18,6 @@ import umc.cockple.demo.domain.chat.repository.ChatMessageRepository; import umc.cockple.demo.domain.chat.repository.ChatRoomMemberRepository; import umc.cockple.demo.domain.chat.repository.ChatRoomRepository; -import umc.cockple.demo.domain.chat.repository.MessageReadStatusRepository; import umc.cockple.demo.domain.chat.service.websocket.ChatRoomListCacheService; import umc.cockple.demo.domain.file.service.FileService; import umc.cockple.demo.domain.member.domain.Member; @@ -31,6 +30,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; @Slf4j @@ -44,8 +44,8 @@ public class ChatQueryServiceImpl implements ChatQueryService { private final ChatMessageRepository chatMessageRepository; private final PartyRepository partyRepository; private final MemberPartyRepository memberPartyRepository; - private final MessageReadStatusRepository messageReadStatusRepository; + private final ChatUnreadQueryService chatUnreadQueryService; private final ChatConverter chatConverter; private final FileService fileService; private final ChatProcessor chatProcessor; @@ -91,6 +91,17 @@ public DirectChatRoomDTO.Response searchDirectChatRoomsByName(Long memberId, Str return response; } + @Override + public ChatUnreadStatusDTO.Response getUnreadStatus(Long memberId) { + log.info("[채팅 안읽음 여부 조회 시작]- 요청자: {}", memberId); + boolean hasPartyUnread = chatUnreadQueryService.hasPartyUnreadMessages(memberId); + boolean hasDirectUnread = chatUnreadQueryService.hasDirectUnreadMessages(memberId); + boolean hasUnread = hasPartyUnread || hasDirectUnread; + log.info("[채팅 안읽음 여부 조회 완료]- hasUnread: {}", hasUnread); + + return chatConverter.toUnreadStatusResponse(hasPartyUnread, hasDirectUnread); + } + @Override public ChatRoomDetailDTO.Response getChatRoomDetail(Long roomId, Long memberId) { log.info("[초기 채팅방 조회 시작] - roomId: {}, memberId: {}", roomId, memberId); @@ -181,21 +192,21 @@ private PartyChatRoomDTO.Response toPartyChatRoomInfos(Slice chatRooms if (chatRooms.isEmpty()) { return chatConverter.toEmptyPartyChatRoomInfos(); } - List roomInfos = chatRooms.stream() + List chatRoomList = chatRooms.getContent(); + List chatRoomIds = chatRoomList.stream() + .map(ChatRoom::getId) + .toList(); + Map unreadCounts = chatUnreadQueryService.countUnreadMessagesByChatRooms(memberId, chatRoomIds); + + List roomInfos = chatRoomList.stream() .map(chatRoom -> { Long chatRoomId = chatRoom.getId(); - ChatRoomMember chatRoomMember = chatRoomMemberRepository.findByChatRoomIdAndMemberId(chatRoomId, memberId) + chatRoomMemberRepository.findByChatRoomIdAndMemberId(chatRoomId, memberId) .orElseThrow(() -> new ChatException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED)); int memberCount = chatRoomMemberRepository.countByChatRoomId(chatRoomId); - Long lastReadMessageId = chatRoomMember.getLastReadMessageId(); - int unreadCount; - if (lastReadMessageId == null) { - unreadCount = messageReadStatusRepository.countAllUnreadMessages(chatRoomId, memberId); - } else { - unreadCount = messageReadStatusRepository.countUnreadMessagesAfter(chatRoomId, memberId, lastReadMessageId); - } + int unreadCount = unreadCounts.getOrDefault(chatRoomId, 0); LastMessageCacheDTO lastMessage = chatRoomListCacheService.getLastMessage(chatRoomId); String imgUrl = getImageUrl(chatRoom.getParty().getPartyImg()); @@ -217,8 +228,14 @@ private DirectChatRoomDTO.Response toDirectChatRoomInfos(Slice chatRoo if (chatRooms.isEmpty()) { return chatConverter.toEmptyDirectChatRoomInfos(); } + List chatRoomList = chatRooms.getContent(); + List chatRoomIds = chatRoomList.stream() + .map(ChatRoom::getId) + .toList(); + Map unreadCounts = chatUnreadQueryService.countUnreadMessagesByChatRooms(memberId, chatRoomIds); + // 각 채팅방에 대해 ChatRoomInfo 생성 - List roomInfos = chatRooms.stream() + List roomInfos = chatRoomList.stream() .map(chatRoom -> { Long chatRoomId = chatRoom.getId(); @@ -232,13 +249,7 @@ private DirectChatRoomDTO.Response toDirectChatRoomInfos(Slice chatRoo .findFirst() .orElseThrow(() -> new ChatException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED)); - Long lastReadMessageId = myMember.getLastReadMessageId(); - int unreadCount; - if (lastReadMessageId == null) { - unreadCount = messageReadStatusRepository.countAllUnreadMessages(chatRoomId, memberId); - } else { - unreadCount = messageReadStatusRepository.countUnreadMessagesAfter(chatRoomId, memberId, lastReadMessageId); - } + int unreadCount = unreadCounts.getOrDefault(chatRoomId, 0); LastMessageCacheDTO lastMessage = chatRoomListCacheService.getLastMessage(chatRoomId); diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/ChatUnreadQueryService.java b/src/main/java/umc/cockple/demo/domain/chat/service/ChatUnreadQueryService.java new file mode 100644 index 000000000..aa5d5dc8f --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/service/ChatUnreadQueryService.java @@ -0,0 +1,98 @@ +package umc.cockple.demo.domain.chat.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import umc.cockple.demo.domain.chat.repository.MessageReadStatusRepository; + +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ChatUnreadQueryService { + + private final MessageReadStatusRepository messageReadStatusRepository; + + public Map countUnreadMessagesByChatRooms(Long memberId, List chatRoomIds) { + Map unreadCounts = initializeZeroCountMap(chatRoomIds); + if (chatRoomIds.isEmpty()) { + return unreadCounts; + } + + messageReadStatusRepository.countUnreadMessagesByChatRooms(memberId, chatRoomIds) + .forEach(count -> unreadCounts.put(count.chatRoomId(), count.unreadCount().intValue())); + + return unreadCounts; + } + + public Map countUnreadMessagesByMembers(Long chatRoomId, List memberIds) { + Map unreadCounts = initializeZeroCountMap(memberIds); + if (memberIds.isEmpty()) { + return unreadCounts; + } + + messageReadStatusRepository.countUnreadMessagesByMembers(chatRoomId, memberIds) + .forEach(count -> unreadCounts.put(count.memberId(), count.unreadCount().intValue())); + + return unreadCounts; + } + + public boolean hasPartyUnreadMessages(Long memberId) { + return messageReadStatusRepository.existsPartyUnreadMessagesByMemberId(memberId); + } + + public boolean hasDirectUnreadMessages(Long memberId) { + return messageReadStatusRepository.existsDirectUnreadMessagesByMemberId(memberId); + } + + public Map findUnreadStatusesByMembers(List memberIds) { + Map unreadStatuses = initializeUnreadStatusMap(memberIds); + if (unreadStatuses.isEmpty()) { + return unreadStatuses; + } + + List distinctMemberIds = List.copyOf(unreadStatuses.keySet()); + Set partyUnreadMemberIds = new HashSet<>( + messageReadStatusRepository.findMemberIdsWithPartyUnreadMessages(distinctMemberIds)); + Set directUnreadMemberIds = new HashSet<>( + messageReadStatusRepository.findMemberIdsWithDirectUnreadMessages(distinctMemberIds)); + + unreadStatuses.replaceAll((memberId, ignored) -> new UnreadStatus( + partyUnreadMemberIds.contains(memberId), + directUnreadMemberIds.contains(memberId) + )); + + return unreadStatuses; + } + + private Map initializeZeroCountMap(List ids) { + Map unreadCounts = new LinkedHashMap<>(); + ids.forEach(id -> unreadCounts.put(id, 0)); + return unreadCounts; + } + + private Map initializeUnreadStatusMap(List memberIds) { + Map unreadStatuses = new LinkedHashMap<>(); + memberIds.forEach(memberId -> unreadStatuses.put(memberId, UnreadStatus.none())); + return unreadStatuses; + } + + public record UnreadStatus( + boolean hasPartyUnread, + boolean hasDirectUnread + ) { + + public static UnreadStatus none() { + return new UnreadStatus(false, false); + } + + public boolean hasUnread() { + return hasPartyUnread || hasDirectUnread; + } + } +} diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/listener/ChatMessageSendEventListener.java b/src/main/java/umc/cockple/demo/domain/chat/service/listener/ChatMessageSendEventListener.java new file mode 100644 index 000000000..076197925 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/service/listener/ChatMessageSendEventListener.java @@ -0,0 +1,29 @@ +package umc.cockple.demo.domain.chat.service.listener; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import umc.cockple.demo.domain.chat.events.ChatMessageSendEvent; +import umc.cockple.demo.domain.chat.service.websocket.send.ChatSendService; + +@Component +@RequiredArgsConstructor +@Slf4j +public class ChatMessageSendEventListener { + + private final ChatSendService chatSendService; + + @EventListener + @Async("chatExecutor") + public void handleChatMessageSend(ChatMessageSendEvent event) { + log.info("메시지 전송 이벤트 처리 - 채팅방: {}, 발신자: {}", + event.chatRoomId(), event.senderId()); + try { + chatSendService.sendMessage(event.chatRoomId(), event.content(), event.files(), event.senderId()); + } catch (Exception e) { + log.error("메시지 전송 이벤트 처리 중 오류 발생", e); + } + } +} diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/listener/ChatNotificationEventListener.java b/src/main/java/umc/cockple/demo/domain/chat/service/listener/ChatNotificationEventListener.java new file mode 100644 index 000000000..83b695198 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/service/listener/ChatNotificationEventListener.java @@ -0,0 +1,29 @@ +package umc.cockple.demo.domain.chat.service.listener; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; +import umc.cockple.demo.domain.notification.events.ChatNotificationEvent; +import umc.cockple.demo.domain.notification.service.ChatPushNotificationService; + +@Component +@RequiredArgsConstructor +@Slf4j +public class ChatNotificationEventListener { + + private final ChatPushNotificationService chatPushNotificationService; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Async("notificationExecutor") + public void handleChatNotification(ChatNotificationEvent event) { + log.info("채팅 알림 이벤트 처리 - 채팅방: {}, 발신자: {}", event.chatRoomId(), event.senderId()); + try { + chatPushNotificationService.sendPush(event); + } catch (Exception e) { + log.error("채팅 알림 이벤트 처리 중 오류 발생 - 채팅방: {}", event.chatRoomId(), e); + } + } +} diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/listener/ChatRoomListUpdateEventListener.java b/src/main/java/umc/cockple/demo/domain/chat/service/listener/ChatRoomListUpdateEventListener.java new file mode 100644 index 000000000..921ed92a4 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/service/listener/ChatRoomListUpdateEventListener.java @@ -0,0 +1,60 @@ +package umc.cockple.demo.domain.chat.service.listener; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; +import umc.cockple.demo.domain.chat.dto.WebSocketMessageDTO.ChatRoomListUpdate.LastMessageUpdate; +import umc.cockple.demo.domain.chat.events.ChatRoomListUpdateEvent; +import umc.cockple.demo.domain.chat.service.websocket.ChatRoomListCacheService; +import umc.cockple.demo.domain.chat.service.websocket.broadcast.ChatRoomListUpdateBroadcaster; +import umc.cockple.demo.domain.chat.service.websocket.broadcast.ChatRoomListUpdateData; + +import java.util.HashMap; +import java.util.Map; + +@Component +@RequiredArgsConstructor +@Slf4j +public class ChatRoomListUpdateEventListener { + + private final ChatRoomListCacheService chatRoomListCacheService; + private final ChatRoomListUpdateBroadcaster chatRoomListUpdateBroadcaster; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Async("chatExecutor") + public void handleChatRoomListUpdate(ChatRoomListUpdateEvent event) { + log.info("채팅방 목록 업데이트 이벤트 처리 시작 - 채팅방: {}", event.chatRoomId()); + + try { + chatRoomListCacheService.evictLastMessage(event.chatRoomId()); + + Map memberUpdateData = new HashMap<>(); + + LastMessageUpdate lastMessageUpdate = LastMessageUpdate.builder() + .content(event.content()) + .timestamp(event.timestamp()) + .messageType(event.messageType()) + .build(); + + for (Map.Entry entry : event.memberUnreadCounts().entrySet()) { + Long memberId = entry.getKey(); + Integer unreadCount = entry.getValue(); + + memberUpdateData.put(memberId, ChatRoomListUpdateData.builder() + .lastMessage(lastMessageUpdate) + .unreadCount(unreadCount) + .build()); + } + + chatRoomListUpdateBroadcaster.broadcast(event.chatRoomId(), memberUpdateData); + + log.info("채팅방 목록 업데이트 이벤트 처리 완료 - 채팅방: {}", event.chatRoomId()); + + } catch (Exception e) { + log.error("채팅방 목록 업데이트 이벤트 처리 중 오류 발생 - 채팅방: {}", event.chatRoomId(), e); + } + } +} diff --git a/src/main/java/umc/cockple/demo/domain/chat/events/ChatRoomRedisCleanupListener.java b/src/main/java/umc/cockple/demo/domain/chat/service/listener/ChatRoomRedisCleanupListener.java similarity index 66% rename from src/main/java/umc/cockple/demo/domain/chat/events/ChatRoomRedisCleanupListener.java rename to src/main/java/umc/cockple/demo/domain/chat/service/listener/ChatRoomRedisCleanupListener.java index a3f881d49..264516471 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/events/ChatRoomRedisCleanupListener.java +++ b/src/main/java/umc/cockple/demo/domain/chat/service/listener/ChatRoomRedisCleanupListener.java @@ -1,4 +1,4 @@ -package umc.cockple.demo.domain.chat.events; +package umc.cockple.demo.domain.chat.service.listener; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -6,9 +6,10 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; -import umc.cockple.demo.domain.chat.service.websocket.ChatListSubscriptionService; +import umc.cockple.demo.domain.chat.events.ChatRoomRedisCleanupEvent; +import umc.cockple.demo.domain.chat.repository.redis.ChatListSubscriptionStore; import umc.cockple.demo.domain.chat.service.websocket.ChatRoomListCacheService; -import umc.cockple.demo.domain.chat.service.websocket.RedisSubscriptionService; +import umc.cockple.demo.domain.chat.repository.redis.ChatRoomSubscriptionStore; @Component @RequiredArgsConstructor @@ -16,11 +17,11 @@ public class ChatRoomRedisCleanupListener { private final ChatRoomListCacheService chatRoomListCacheService; - private final RedisSubscriptionService redisSubscriptionService; - private final ChatListSubscriptionService chatListSubscriptionService; + private final ChatRoomSubscriptionStore chatRoomSubscriptionStore; + private final ChatListSubscriptionStore chatListSubscriptionStore; @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - @Async + @Async("chatExecutor") public void handleChatRoomRedisCleanup(ChatRoomRedisCleanupEvent event) { Long chatRoomId = event.chatRoomId(); log.info("[채팅방 Redis 정리 시작] - chatRoomId: {}", chatRoomId); @@ -31,8 +32,8 @@ public void handleChatRoomRedisCleanup(ChatRoomRedisCleanupEvent event) { log.warn("[채팅방 Redis 정리] 마지막 메시지 캐시 best-effort 삭제 실패 - chatRoomId: {}", chatRoomId, e); } - redisSubscriptionService.tryClearRoomSubscribers(chatRoomId); - chatListSubscriptionService.tryClearChatListSubscribers(chatRoomId); + chatRoomSubscriptionStore.tryClearRoomSubscribers(chatRoomId); + chatListSubscriptionStore.tryClearChatListSubscribers(chatRoomId); log.info("[채팅방 Redis 정리 완료] - chatRoomId: {}", chatRoomId); } diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/listener/ChatSubscriptionEventListener.java b/src/main/java/umc/cockple/demo/domain/chat/service/listener/ChatSubscriptionEventListener.java new file mode 100644 index 000000000..8bb50713b --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/service/listener/ChatSubscriptionEventListener.java @@ -0,0 +1,75 @@ +package umc.cockple.demo.domain.chat.service.listener; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import umc.cockple.demo.domain.chat.events.ChatListSubscriptionEvent; +import umc.cockple.demo.domain.chat.events.ChatRoomSubscriptionEvent; +import umc.cockple.demo.domain.chat.repository.redis.ChatListSubscriptionStore; +import umc.cockple.demo.domain.chat.service.websocket.subscription.ChatRoomSubscriptionService; + +@Component +@RequiredArgsConstructor +@Slf4j +public class ChatSubscriptionEventListener { + + /* + * 구독 이벤트 후속 처리는 best-effort 계약이다. + * + * WebSocket command handler는 요청 검증과 이벤트 발행이 끝나면 ACK를 보낸다. 이 listener는 Redis 구독 상태, + * 읽음 처리, unread-count 브로드캐스트 실패를 error log로 남기고 삼킨다. 현재 재시도나 보상 정책은 없으므로, + * ACK를 모든 후속 side effect 완료 보장으로 해석하면 안 된다. + */ + + private final ChatRoomSubscriptionService chatRoomSubscriptionService; + private final ChatListSubscriptionStore chatListSubscriptionStore; + + @EventListener + public void handleChatRoomSubscription(ChatRoomSubscriptionEvent event) { + log.info("채팅방 구독 이벤트 처리 - 채팅방: {}, 사용자: {}, 액션: {}", + event.chatRoomId(), event.memberId(), event.action()); + + try { + switch (event.action()) { + case "SUBSCRIBE" -> { + chatRoomSubscriptionService.subscribeToChatRoom(event.chatRoomId(), event.memberId()); + log.info("사용자 {}가 채팅방 {}를 구독했습니다.", event.memberId(), event.chatRoomId()); + } + case "UNSUBSCRIBE" -> { + chatRoomSubscriptionService.unsubscribeToChatRoom(event.chatRoomId(), event.memberId()); + log.info("사용자 {}가 채팅방 {}를 구독해제했습니다.", event.memberId(), event.chatRoomId()); + } + default -> log.warn("알 수 없는 구독 액션: {}", event.action()); + } + } catch (Exception e) { + log.error("채팅방 구독 이벤트 처리 중 오류 발생", e); + } + } + + @EventListener + @Async("chatExecutor") + public void handleChatListSubscription(ChatListSubscriptionEvent event) { + log.info("채팅방 목록 구독 이벤트 처리 시작 - 멤버: {}, 액션: {}, 채팅방 수: {}", + event.memberId(), event.action(), event.chatRoomIds().size()); + + try { + switch (event.action()) { + case "SUBSCRIBE" -> { + chatListSubscriptionStore.subscribeToChatList(event.memberId(), event.chatRoomIds()); + log.info("채팅방 목록 구독 완료 - 멤버: {}, 채팅방 수: {}", event.memberId(), event.chatRoomIds().size()); + } + case "UNSUBSCRIBE" -> { + chatListSubscriptionStore.unsubscribeFromChatList(event.memberId(), event.chatRoomIds()); + log.info("채팅방 목록 구독 해제 완료 - 멤버: {}, 채팅방 수: {}", event.memberId(), event.chatRoomIds().size()); + } + default -> log.warn("알 수 없는 채팅방 목록 구독 액션: {}", event.action()); + } + + } catch (Exception e) { + log.error("채팅방 목록 구독 이벤트 처리 중 오류 발생 - 멤버: {}, 액션: {}", + event.memberId(), event.action(), e); + } + } +} diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/listener/ChatUnreadStatusUpdateEventListener.java b/src/main/java/umc/cockple/demo/domain/chat/service/listener/ChatUnreadStatusUpdateEventListener.java new file mode 100644 index 000000000..414704dcd --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/service/listener/ChatUnreadStatusUpdateEventListener.java @@ -0,0 +1,68 @@ +package umc.cockple.demo.domain.chat.service.listener; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; +import umc.cockple.demo.domain.chat.dto.WebSocketMessageDTO; +import umc.cockple.demo.domain.chat.enums.WebSocketMessageType; +import umc.cockple.demo.domain.chat.events.ChatUnreadStatusUpdateEvent; +import umc.cockple.demo.domain.chat.service.ChatUnreadQueryService; +import umc.cockple.demo.domain.chat.service.websocket.session.ChatMessageEncoder; +import umc.cockple.demo.domain.chat.service.websocket.session.ChatMessageSender; +import umc.cockple.demo.domain.chat.service.websocket.session.ChatSessionRegistry; +import umc.cockple.demo.domain.chat.service.websocket.session.EncodedChatMessage; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +@Component +@RequiredArgsConstructor +@Slf4j +public class ChatUnreadStatusUpdateEventListener { + + private final ChatMessageSender chatMessageSender; + private final ChatMessageEncoder chatMessageEncoder; + private final ChatUnreadQueryService chatUnreadQueryService; + private final ChatSessionRegistry chatSessionRegistry; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Async("chatExecutor") + public void handleChatUnreadStatusUpdate(ChatUnreadStatusUpdateEvent event) { + log.info("채팅 안읽음 상태 업데이트 이벤트 처리 시작 - 대상자: {}명", event.targetMemberIds().size()); + + List openMemberIds = chatSessionRegistry.findOpenMemberIds(event.targetMemberIds()); + if (openMemberIds.isEmpty()) { + log.debug("열린 WebSocket 세션이 있는 안읽음 상태 업데이트 대상 없음"); + return; + } + + Map unreadStatuses = + chatUnreadQueryService.findUnreadStatusesByMembers(openMemberIds); + + for (Map.Entry entry : unreadStatuses.entrySet()) { + Long memberId = entry.getKey(); + ChatUnreadQueryService.UnreadStatus unreadStatus = entry.getValue(); + try { + WebSocketMessageDTO.UnreadStatusUpdateMessage message = + WebSocketMessageDTO.UnreadStatusUpdateMessage.builder() + .type(WebSocketMessageType.UNREAD_STATUS_UPDATE) + .hasUnread(unreadStatus.hasUnread()) + .hasPartyUnread(unreadStatus.hasPartyUnread()) + .hasDirectUnread(unreadStatus.hasDirectUnread()) + .timestamp(LocalDateTime.now()) + .build(); + + EncodedChatMessage encodedMessage = chatMessageEncoder.encode(message).orElse(null); + if (encodedMessage != null) { + chatMessageSender.send(memberId, encodedMessage); + } + } catch (Exception e) { + log.error("채팅 안읽음 상태 업데이트 처리 실패 - 멤버: {}", memberId, e); + } + } + } +} diff --git a/src/main/java/umc/cockple/demo/domain/chat/events/MemberWithdrawnChatAnonymizeListener.java b/src/main/java/umc/cockple/demo/domain/chat/service/listener/MemberWithdrawnChatAnonymizeListener.java similarity index 93% rename from src/main/java/umc/cockple/demo/domain/chat/events/MemberWithdrawnChatAnonymizeListener.java rename to src/main/java/umc/cockple/demo/domain/chat/service/listener/MemberWithdrawnChatAnonymizeListener.java index ce3be9097..d7b7b5ac4 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/events/MemberWithdrawnChatAnonymizeListener.java +++ b/src/main/java/umc/cockple/demo/domain/chat/service/listener/MemberWithdrawnChatAnonymizeListener.java @@ -1,4 +1,4 @@ -package umc.cockple.demo.domain.chat.events; +package umc.cockple.demo.domain.chat.service.listener; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/listener/PartyChatSystemMessageListener.java b/src/main/java/umc/cockple/demo/domain/chat/service/listener/PartyChatSystemMessageListener.java new file mode 100644 index 000000000..c88ff845c --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/service/listener/PartyChatSystemMessageListener.java @@ -0,0 +1,27 @@ +package umc.cockple.demo.domain.chat.service.listener; + +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; +import umc.cockple.demo.domain.chat.service.websocket.send.ChatSendService; +import umc.cockple.demo.domain.party.events.PartyMemberJoinedEvent; + +@Component +@RequiredArgsConstructor +public class PartyChatSystemMessageListener { + + private final ChatSendService chatSendService; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Async("chatExecutor") + public void handlePartyMemberChanged(PartyMemberJoinedEvent event) { + switch (event.action()) { + case JOINED -> chatSendService.sendSystemMessage(event.partyId(), + event.memberName() + "님이 모임에 참여하셨습니다."); + case LEFT -> chatSendService.sendSystemMessage(event.partyId(), + event.memberName() + "님이 모임을 떠나셨습니다."); + } + } +} diff --git a/src/main/java/umc/cockple/demo/domain/chat/events/PartyDeletedChatCleanupListener.java b/src/main/java/umc/cockple/demo/domain/chat/service/listener/PartyDeletedChatCleanupListener.java similarity index 92% rename from src/main/java/umc/cockple/demo/domain/chat/events/PartyDeletedChatCleanupListener.java rename to src/main/java/umc/cockple/demo/domain/chat/service/listener/PartyDeletedChatCleanupListener.java index 32daa35dd..482f842b2 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/events/PartyDeletedChatCleanupListener.java +++ b/src/main/java/umc/cockple/demo/domain/chat/service/listener/PartyDeletedChatCleanupListener.java @@ -1,4 +1,4 @@ -package umc.cockple.demo.domain.chat.events; +package umc.cockple.demo.domain.chat.service.listener; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/support/ReadStatusBatchSupport.java b/src/main/java/umc/cockple/demo/domain/chat/service/support/ReadStatusBatchSupport.java new file mode 100644 index 000000000..8c2548787 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/service/support/ReadStatusBatchSupport.java @@ -0,0 +1,30 @@ +package umc.cockple.demo.domain.chat.service.support; + +import java.util.ArrayList; +import java.util.List; + +public final class ReadStatusBatchSupport { + + public static final int IN_CLAUSE_CHUNK_SIZE = 500; + + private ReadStatusBatchSupport() { + } + + public static List> chunk(List values) { + if (values.isEmpty()) { + return List.of(); + } + + List> chunks = new ArrayList<>(chunkCount(values.size())); + for (int start = 0; start < values.size(); start += IN_CLAUSE_CHUNK_SIZE) { + int end = Math.min(start + IN_CLAUSE_CHUNK_SIZE, values.size()); + chunks.add(values.subList(start, end)); + } + + return chunks; + } + + private static int chunkCount(int size) { + return (size + IN_CLAUSE_CHUNK_SIZE - 1) / IN_CLAUSE_CHUNK_SIZE; + } +} diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/support/reader/ChatMemberReader.java b/src/main/java/umc/cockple/demo/domain/chat/service/support/reader/ChatMemberReader.java new file mode 100644 index 000000000..27f604a84 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/service/support/reader/ChatMemberReader.java @@ -0,0 +1,22 @@ +package umc.cockple.demo.domain.chat.service.support.reader; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import umc.cockple.demo.domain.chat.exception.ChatErrorCode; +import umc.cockple.demo.domain.chat.exception.ChatException; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.repository.MemberRepository; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ChatMemberReader { + + private final MemberRepository memberRepository; + + public Member readWithProfile(Long memberId) { + return memberRepository.findMemberWithProfileById(memberId) + .orElseThrow(() -> new ChatException(ChatErrorCode.MEMBER_NOT_FOUND)); + } +} diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/support/reader/ChatRoomReader.java b/src/main/java/umc/cockple/demo/domain/chat/service/support/reader/ChatRoomReader.java new file mode 100644 index 000000000..8c75900a8 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/service/support/reader/ChatRoomReader.java @@ -0,0 +1,27 @@ +package umc.cockple.demo.domain.chat.service.support.reader; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import umc.cockple.demo.domain.chat.domain.ChatRoom; +import umc.cockple.demo.domain.chat.exception.ChatErrorCode; +import umc.cockple.demo.domain.chat.exception.ChatException; +import umc.cockple.demo.domain.chat.repository.ChatRoomRepository; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ChatRoomReader { + + private final ChatRoomRepository chatRoomRepository; + + public ChatRoom read(Long chatRoomId) { + return chatRoomRepository.findById(chatRoomId) + .orElseThrow(() -> new ChatException(ChatErrorCode.CHAT_ROOM_NOT_FOUND)); + } + + public ChatRoom readByPartyId(Long partyId) { + return chatRoomRepository.findByPartyId(partyId) + .orElseThrow(() -> new ChatException(ChatErrorCode.CHAT_ROOM_NOT_FOUND)); + } +} diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/support/reader/ReadStatusReader.java b/src/main/java/umc/cockple/demo/domain/chat/service/support/reader/ReadStatusReader.java new file mode 100644 index 000000000..d5f40ddcc --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/service/support/reader/ReadStatusReader.java @@ -0,0 +1,44 @@ +package umc.cockple.demo.domain.chat.service.support.reader; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import umc.cockple.demo.domain.chat.repository.MessageReadStatusRepository; +import umc.cockple.demo.domain.chat.service.support.ReadStatusBatchSupport; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ReadStatusReader { + + private final MessageReadStatusRepository messageReadStatusRepository; + + public List findUnreadMessageIds(Long chatRoomId, Long memberId) { + return messageReadStatusRepository.findUnreadMessageIdsByMember(chatRoomId, memberId); + } + + public Map countUnreadByMessageIdsAsSparseMap(List messageIds) { + if (messageIds.isEmpty()) { + return Map.of(); + } + + Map unreadCounts = new HashMap<>(); + for (List chunk : ReadStatusBatchSupport.chunk(messageIds)) { + messageReadStatusRepository.countUnreadByMessageIds(chunk) + .forEach(count -> unreadCounts.put( + count.chatMessageId(), + count.unreadCount().intValue() + )); + } + + return unreadCounts; + } + + public int countUnreadByMessageId(Long messageId) { + return messageReadStatusRepository.countUnreadByMessageId(messageId); + } +} diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/support/updater/ChatMemberReadStateUpdater.java b/src/main/java/umc/cockple/demo/domain/chat/service/support/updater/ChatMemberReadStateUpdater.java new file mode 100644 index 000000000..2cbd10f84 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/service/support/updater/ChatMemberReadStateUpdater.java @@ -0,0 +1,61 @@ +package umc.cockple.demo.domain.chat.service.support.updater; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import umc.cockple.demo.domain.chat.repository.ChatRoomMemberRepository; +import umc.cockple.demo.domain.chat.service.support.ReadStatusBatchSupport; + +import java.util.List; + +@Service +@Transactional +@RequiredArgsConstructor +@Slf4j +public class ChatMemberReadStateUpdater { + + private final ChatRoomMemberRepository chatRoomMemberRepository; + + public int advanceLastReadMessageId(Long chatRoomId, Long memberId, Long messageId) { + int updatedCount = chatRoomMemberRepository.advanceLastReadMessageId(chatRoomId, memberId, messageId); + if (updatedCount == 0) { + log.warn( + "lastReadMessageId 미갱신 - 채팅방: {}, 멤버: {}, 메시지: {} (멤버십 없음 또는 이미 최신 상태)", + chatRoomId, + memberId, + messageId + ); + } + + return updatedCount; + } + + public int advanceLastReadMessageIdForMembers(Long chatRoomId, List memberIds, Long messageId) { + if (memberIds.isEmpty()) { + return 0; + } + + int updatedCount = 0; + for (List chunk : ReadStatusBatchSupport.chunk(memberIds)) { + updatedCount += chatRoomMemberRepository.advanceLastReadMessageIdForMembers( + chatRoomId, + chunk, + messageId + ); + } + + if (updatedCount < memberIds.size()) { + log.warn( + "lastReadMessageId 일부 미갱신 - 채팅방: {}, 요청 멤버 수: {}, 갱신 수: {}, 메시지: {} " + + "(멤버십 없음 또는 이미 최신 상태 포함 가능)", + chatRoomId, + memberIds.size(), + updatedCount, + messageId + ); + } + + return updatedCount; + } +} diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/support/updater/ReadStatusUpdater.java b/src/main/java/umc/cockple/demo/domain/chat/service/support/updater/ReadStatusUpdater.java new file mode 100644 index 000000000..d413425fc --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/service/support/updater/ReadStatusUpdater.java @@ -0,0 +1,43 @@ +package umc.cockple.demo.domain.chat.service.support.updater; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import umc.cockple.demo.domain.chat.repository.MessageReadStatusRepository; +import umc.cockple.demo.domain.chat.service.support.ReadStatusBatchSupport; + +import java.util.List; + +@Service +@Transactional +@RequiredArgsConstructor +public class ReadStatusUpdater { + + private final MessageReadStatusRepository messageReadStatusRepository; + + public int markMessagesAsReadForMember(Long chatRoomId, Long memberId, List messageIds) { + if (messageIds.isEmpty()) { + return 0; + } + + int updatedCount = 0; + for (List chunk : ReadStatusBatchSupport.chunk(messageIds)) { + updatedCount += messageReadStatusRepository.markMessagesAsReadForMember(chatRoomId, memberId, chunk); + } + + return updatedCount; + } + + public int markMessageAsReadForMembers(Long messageId, List memberIds) { + if (memberIds.isEmpty()) { + return 0; + } + + int updatedCount = 0; + for (List chunk : ReadStatusBatchSupport.chunk(memberIds)) { + updatedCount += messageReadStatusRepository.markAsReadInMembers(messageId, chunk); + } + + return updatedCount; + } +} diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/websocket/ChatReadService.java b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/ChatReadService.java deleted file mode 100644 index 3c21ed563..000000000 --- a/src/main/java/umc/cockple/demo/domain/chat/service/websocket/ChatReadService.java +++ /dev/null @@ -1,64 +0,0 @@ -package umc.cockple.demo.domain.chat.service.websocket; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import umc.cockple.demo.domain.chat.domain.ChatRoomMember; -import umc.cockple.demo.domain.chat.repository.ChatRoomMemberRepository; -import umc.cockple.demo.domain.chat.repository.MessageReadStatusRepository; - -import java.util.List; -import java.util.Optional; - -@Service -@Transactional -@RequiredArgsConstructor -@Slf4j -public class ChatReadService { - - private final MessageReadStatusRepository messageReadStatusRepository; - private final ChatRoomMemberRepository chatRoomMemberRepository; - - @Transactional - public int subscribersToReadStatus(Long chatRoomId, Long messageId, List activeSubscribers, Long senderId) { - log.info("초기 읽음 처리 - 메시지: {}, 활성 구독자 수: {}, 발신자: {}", - messageId, activeSubscribers.size(), senderId); - - List readers = activeSubscribers.stream() - .filter(memberId -> !memberId.equals(senderId)) - .toList(); - - if (!readers.isEmpty()) { - int updatedCount = messageReadStatusRepository.markAsReadInMembers(messageId, readers); - log.info("초기 읽음 처리 완료 - 처리된 구독자: {}명", updatedCount); - - updateLastReadMessageIds(chatRoomId, messageId, readers); - } - - int finalUnreadCount = messageReadStatusRepository.countUnreadByMessageId(messageId); - log.info("초기 처리 후 최종 안읽은 수: {}", finalUnreadCount); - - return finalUnreadCount; - } - - private void updateLastReadMessageIds(Long chatRoomId, Long messageId, List memberIds) { - for (Long memberId : memberIds) { - try { - Optional chatRoomMemberOpt = - chatRoomMemberRepository.findByChatRoomIdAndMemberId(chatRoomId, memberId); - - if (chatRoomMemberOpt.isPresent()) { - ChatRoomMember chatRoomMember = chatRoomMemberOpt.get(); - - if (chatRoomMember.getLastReadMessageId() == null || - messageId > chatRoomMember.getLastReadMessageId()) { - chatRoomMember.updateLastReadMessageId(messageId); - } - } - } catch (Exception e) { - log.error("lastReadMessageId 업데이트 실패 - 멤버: {}, 메시지: {}", memberId, messageId, e); - } - } - } -} diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/websocket/ChatSendService.java b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/ChatSendService.java deleted file mode 100644 index c9077db75..000000000 --- a/src/main/java/umc/cockple/demo/domain/chat/service/websocket/ChatSendService.java +++ /dev/null @@ -1,250 +0,0 @@ -package umc.cockple.demo.domain.chat.service.websocket; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import umc.cockple.demo.domain.chat.converter.ChatConverter; -import umc.cockple.demo.domain.chat.domain.*; -import umc.cockple.demo.domain.chat.dto.ChatCommonDTO; -import umc.cockple.demo.domain.chat.dto.WebSocketMessageDTO; -import umc.cockple.demo.domain.chat.dto.WebSocketMessageDTO.Request.FileInfo; -import umc.cockple.demo.domain.chat.enums.ChatRoomType; -import umc.cockple.demo.domain.chat.enums.MessageType; -import umc.cockple.demo.domain.chat.events.ChatRoomListUpdateEvent; -import umc.cockple.demo.domain.chat.exception.ChatErrorCode; -import umc.cockple.demo.domain.chat.exception.ChatException; -import umc.cockple.demo.domain.chat.repository.ChatMessageRepository; -import umc.cockple.demo.domain.chat.repository.ChatRoomMemberRepository; -import umc.cockple.demo.domain.chat.repository.ChatRoomRepository; -import umc.cockple.demo.domain.chat.repository.MessageReadStatusRepository; -import umc.cockple.demo.domain.chat.service.ChatProcessor; -import umc.cockple.demo.domain.member.domain.Member; -import umc.cockple.demo.domain.member.repository.MemberRepository; -import umc.cockple.demo.domain.notification.events.ChatNotificationEvent; - -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; - -@Service -@Transactional -@RequiredArgsConstructor -@Slf4j -public class ChatSendService { - - private final ChatRoomRepository chatRoomRepository; - private final MemberRepository memberRepository; - private final ChatMessageRepository chatMessageRepository; - private final ChatRoomMemberRepository chatRoomMemberRepository; - private final MessageReadStatusRepository messageReadStatusRepository; - - private final SubscriptionService subscriptionService; - private final MessageReadCreationService messageReadCreationService; - private final ChatProcessor chatProcessor; - private final ChatConverter chatConverter; - private final ChatReadService chatReadService; - private final ApplicationEventPublisher eventPublisher; - - public void sendMessage(Long chatRoomId, String content, List files, Long senderId) { - log.info("메시지 전송 시작 - 채팅방: {}, 발신자: {}", chatRoomId, senderId); - - ChatRoom chatRoom = findChatRoom(chatRoomId); - Member sender = findMemberWithProfile(senderId); - - String profileImageUrl = chatProcessor.generateProfileImageUrl(sender.getProfileImg()); - - ChatMessage chatMessage = ChatMessage.create(chatRoom, sender, content, MessageType.TEXT); - attachFiles(chatMessage, files); - ChatMessage savedMessage = chatMessageRepository.save(chatMessage); - log.info("메시지 저장 완료 - 메시지 ID: {}", savedMessage.getId()); - - checkFirstMessageInDirect(chatRoomId, senderId, chatRoom); - messageReadCreationService.createReadStatusForNewMessage(savedMessage, senderId); - - List activeSubscribers = subscriptionService.getActiveSubscribers(chatRoomId); - int unreadCount = chatReadService.subscribersToReadStatus(chatRoom.getId(), savedMessage.getId(), activeSubscribers, senderId); - - List responseFiles = - createResponseFileInfos(savedMessage.getChatMessageFiles()); - - log.info("메시지 브로드캐스트 시작 - 채팅방 ID: {}", chatRoomId); - WebSocketMessageDTO.MessageResponse response = - chatConverter.toSendMessageResponse(chatRoomId, content, responseFiles, savedMessage, sender, profileImageUrl, unreadCount); - subscriptionService.broadcastMessage(chatRoomId, response, senderId); - log.info("메시지 브로드캐스트 완료 - 채팅방 ID: {}", chatRoomId); - - // 알림 이벤트 발행 - publishChatNotificationEvent(chatRoom, savedMessage, sender, activeSubscribers); - - publishChatRoomListUpdateEvent(chatRoom, savedMessage); - } - - public void sendSystemMessage(Long partyId, String content) { - ChatRoom chatRoom = findChatRoomByPartyId(partyId); - - ChatMessage systemMessage = ChatMessage.create(chatRoom, null, content, MessageType.SYSTEM); - ChatMessage savedSystemMessage = chatMessageRepository.save(systemMessage); - - messageReadCreationService.createReadStatusForNewMessage(savedSystemMessage, null); - - List activeSubscribers = subscriptionService.getActiveSubscribers(chatRoom.getId()); - chatReadService.subscribersToReadStatus( - chatRoom.getId(), - savedSystemMessage.getId(), - activeSubscribers, - null - ); - - WebSocketMessageDTO.MessageResponse broadcastSystemMessage - = chatConverter.toSystemMessageResponse(chatRoom.getId(), content, savedSystemMessage); - - subscriptionService.broadcastSystemMessage(chatRoom.getId(), broadcastSystemMessage); - publishChatRoomListUpdateEvent(chatRoom, savedSystemMessage); - log.info("시스템 메시지 브로드캐스트 완료 - chatRoomId: {}", chatRoom.getId()); - } - - // ========== 비즈니스 메서드 ========== - private void attachFiles(ChatMessage message, List files) { - if (files != null && !files.isEmpty()) { - files.forEach(fileInfo -> { - ChatMessageFile messageFile = ChatMessageFile.create( - message, fileInfo.imgKey(), fileInfo.imgOrder(), - fileInfo.originalFileName(), fileInfo.fileSize(), fileInfo.fileType() - ); - message.getChatMessageFiles().add(messageFile); - }); - } - } - - private void checkFirstMessageInDirect(Long chatRoomId, Long senderId, ChatRoom chatRoom) { - if (chatRoom.getType() == ChatRoomType.DIRECT && isFirstMessage(chatRoomId)) { - handleFirstDirectMessage(chatRoomId, senderId); - } - } - - private boolean isFirstMessage(Long chatRoomId) { - return chatMessageRepository.countByChatRoomId(chatRoomId) == 1; - } - - private void handleFirstDirectMessage(Long chatRoomId, Long senderId) { - log.info("첫 번째 개인 메시지 처리 - 채팅방: {}", chatRoomId); - Optional pendingMemberOpt = chatRoomMemberRepository.findPendingMemberInDirect(chatRoomId, senderId); - - if (pendingMemberOpt.isPresent()) { - ChatRoomMember pendingMember = pendingMemberOpt.get(); - pendingMember.joinChatRoom(); - - Long targetMemberId = pendingMember.getMember().getId(); - log.info("PENDING 멤버를 JOINED로 변경 완료 - 멤버 ID: {}", targetMemberId); - } - } - - private List createResponseFileInfos( - List savedFiles) { - return savedFiles.stream() - .map(file -> ChatCommonDTO.FileInfo.builder() - .imageId(file.getId()) - .imageUrl(chatProcessor.generateFileUrl(file)) - .imgOrder(file.getFileOrder()) - .isEmoji(file.getIsEmoji()) - .originalFileName(file.getOriginalFileName()) - .fileSize(file.getFileSize()) - .fileType(file.getFileType()) - .build()) - .toList(); - } - - private void publishChatRoomListUpdateEvent(ChatRoom chatRoom, ChatMessage savedMessage) { - try { - List chatRoomMemberIds = chatRoomMemberRepository.findMemberIdsByChatRoomId(chatRoom.getId()); - - Map memberUnreadCounts = calculateUnreadCountForMembers( - chatRoom.getId(), chatRoomMemberIds); - - ChatRoomListUpdateEvent listUpdateEvent = ChatRoomListUpdateEvent.create( - chatRoom.getId(), - savedMessage.getDisplayContent(), - savedMessage.getCreatedAt(), - savedMessage.getType().name(), - memberUnreadCounts - ); - - eventPublisher.publishEvent(listUpdateEvent); - log.info("채팅방 목록 업데이트 이벤트 발행 - 채팅방: {}", chatRoom.getId()); - - } catch (Exception e) { - log.error("채팅방 목록 업데이트 이벤트 발행 실패 - 채팅방: {}", chatRoom.getId(), e); - } - } - - private Map calculateUnreadCountForMembers( - Long chatRoomId, List memberIds) { - - Map unreadCounts = new HashMap<>(); - - for (Long memberId : memberIds) { - try { - Optional memberOpt = chatRoomMemberRepository.findByChatRoomIdAndMemberId(chatRoomId, memberId); - - int unreadCount; - if (memberOpt.isPresent()) { - Long lastReadMessageId = memberOpt.get().getLastReadMessageId(); - - if (lastReadMessageId == null) { - unreadCount = messageReadStatusRepository.countAllUnreadMessages(chatRoomId, memberId); - } else { - unreadCount = messageReadStatusRepository.countUnreadMessagesAfter(chatRoomId, memberId, lastReadMessageId); - } - } else { - unreadCount = 0; - } - - unreadCounts.put(memberId, unreadCount); - - } catch (Exception e) { - log.error("멤버 {} 안 읽은 메시지 수 계산 실패 - 채팅방: {}", memberId, chatRoomId, e); - unreadCounts.put(memberId, 0); - } - } - - return unreadCounts; - } - - // 채팅 알림 이벤트 발행 - private void publishChatNotificationEvent(ChatRoom chatRoom, ChatMessage savedMessage, - Member sender, List activeSubscribers) { - String notificationTitle = chatRoom.getType() == ChatRoomType.PARTY - ? chatRoom.getParty().getPartyName() - : sender.getNickname(); - String notificationContent = chatRoom.getType() == ChatRoomType.PARTY - ? sender.getNickname() + ": " + savedMessage.getDisplayContent() - : savedMessage.getDisplayContent(); - - eventPublisher.publishEvent(ChatNotificationEvent.create( - chatRoom.getId(), - chatRoom.getType(), - notificationTitle, - notificationContent, - sender.getId(), - activeSubscribers - )); - } - - private ChatRoom findChatRoom(Long chatRoomId) { - return chatRoomRepository.findById(chatRoomId) - .orElseThrow(() -> new ChatException(ChatErrorCode.CHAT_ROOM_NOT_FOUND)); - } - - private ChatRoom findChatRoomByPartyId(Long partyId) { - return chatRoomRepository.findByPartyId(partyId) - .orElseThrow(() -> new ChatException(ChatErrorCode.CHAT_ROOM_NOT_FOUND)); - } - - private Member findMemberWithProfile(Long senderId) { - return memberRepository.findMemberWithProfileById(senderId) - .orElseThrow(() -> new ChatException(ChatErrorCode.MEMBER_NOT_FOUND)); - } -} diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/websocket/SubscriptionReadProcessingService.java b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/SubscriptionReadProcessingService.java deleted file mode 100644 index 48e6e104b..000000000 --- a/src/main/java/umc/cockple/demo/domain/chat/service/websocket/SubscriptionReadProcessingService.java +++ /dev/null @@ -1,88 +0,0 @@ -package umc.cockple.demo.domain.chat.service.websocket; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import umc.cockple.demo.domain.chat.domain.ChatRoomMember; -import umc.cockple.demo.domain.chat.repository.ChatRoomMemberRepository; -import umc.cockple.demo.domain.chat.repository.MessageReadStatusRepository; - -import java.util.List; -import java.util.Optional; - -@Service -@Transactional -@RequiredArgsConstructor -@Slf4j -public class SubscriptionReadProcessingService { - - private final MessageReadStatusRepository messageReadStatusRepository; - private final ChatRoomMemberRepository chatRoomMemberRepository; - - public List processUnreadMessagesOnSubscribe(Long chatRoomId, Long memberId) { - log.info("구독 시 안읽은 메시지 처리 시작 - 채팅방: {}, 멤버: {}", chatRoomId, memberId); - - List unreadMessageIds = messageReadStatusRepository.findUnreadMessageIdsByMember(chatRoomId, memberId); - - if (unreadMessageIds.isEmpty()) { - log.debug("처리할 안읽은 메시지가 없음 - 채팅방: {}, 멤버: {}", chatRoomId, memberId); - return List.of(); - } - log.debug("처리할 안읽은 메시지 수: {} - 채팅방: {}, 멤버: {}", unreadMessageIds.size(), chatRoomId, memberId); - - List updates = unreadMessageIds.stream() - .map(messageId -> { - log.debug("메시지 읽음 처리 중 - 메시지: {}, 멤버: {}", messageId, memberId); - int processedCount = messageReadStatusRepository.markAsReadInMembers(messageId, List.of(memberId)); - int newUnreadCount = messageReadStatusRepository.countUnreadByMessageId(messageId); - log.debug("메시지 읽음 처리 완료 - 메시지: {}, 처리 결과: {}, 새 안읽은 수: {}", - messageId, processedCount > 0 ? "성공" : "이미 읽음", newUnreadCount); - - return new MessageUnreadUpdate(messageId, newUnreadCount); - }) - .toList(); - - if (!unreadMessageIds.isEmpty()) { - Long latestMessageId = unreadMessageIds.get(unreadMessageIds.size() - 1); - updateLastReadMessageId(chatRoomId, memberId, latestMessageId); - log.debug("lastReadMessageId 업데이트 완료 - 채팅방: {}, 멤버: {}, 최신 메시지: {}", - chatRoomId, memberId, latestMessageId); - } - - log.info("구독 시 안읽은 메시지 처리 완료 - 채팅방: {}, 멤버: {}, 처리된 메시지 수: {}", - chatRoomId, memberId, updates.size()); - return updates; - } - - private void updateLastReadMessageId(Long chatRoomId, Long memberId, Long messageId) { - try { - Optional chatRoomMemberOpt = - chatRoomMemberRepository.findByChatRoomIdAndMemberId(chatRoomId, memberId); - - if (chatRoomMemberOpt.isPresent()) { - ChatRoomMember chatRoomMember = chatRoomMemberOpt.get(); - - if (chatRoomMember.getLastReadMessageId() == null || - messageId > chatRoomMember.getLastReadMessageId()) { - - Long previousLastRead = chatRoomMember.getLastReadMessageId(); - chatRoomMember.updateLastReadMessageId(messageId); - - log.debug("구독 시 lastReadMessageId 업데이트 - 멤버: {}, 이전: {}, 새로운: {}", - memberId, previousLastRead, messageId); - } - } else { - log.warn("ChatRoomMember를 찾을 수 없음 - 채팅방: {}, 멤버: {}", chatRoomId, memberId); - } - } catch (Exception e) { - log.error("구독 시 lastReadMessageId 업데이트 실패 - 멤버: {}, 메시지: {}", memberId, messageId, e); - } - } - - public record MessageUnreadUpdate( - Long messageId, - int newUnreadCount - ) { - } -} diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/websocket/SubscriptionService.java b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/SubscriptionService.java deleted file mode 100644 index 290d21a87..000000000 --- a/src/main/java/umc/cockple/demo/domain/chat/service/websocket/SubscriptionService.java +++ /dev/null @@ -1,228 +0,0 @@ -package umc.cockple.demo.domain.chat.service.websocket; - -import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.Builder; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.web.socket.TextMessage; -import org.springframework.web.socket.WebSocketSession; -import umc.cockple.demo.domain.chat.dto.WebSocketMessageDTO; -import umc.cockple.demo.domain.chat.dto.WebSocketMessageDTO.ChatRoomListUpdate.LastMessageUpdate; -import umc.cockple.demo.domain.chat.enums.WebSocketMessageType; - -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; - -@Service -@RequiredArgsConstructor -@Slf4j -public class SubscriptionService { - - private final ObjectMapper objectMapper; - - private final SubscriptionReadProcessingService subscriptionReadProcessingService; - private final RedisSubscriptionService redisSubscriptionService; - private final ChatListSubscriptionService chatListSubscriptionService; - - private final Map memberSessions = new ConcurrentHashMap<>(); - - public void addSession(Long memberId, WebSocketSession session) { - memberSessions.put(memberId, session); - } - - public void removeSession(Long memberId) { - memberSessions.remove(memberId); - log.info("로컬 세션 제거 - 멤버: {}", memberId); - } - - public void subscribeToChatRoom(Long chatRoomId, Long memberId) { - redisSubscriptionService.addSubscriber(chatRoomId, memberId); - log.info("채팅방 구독 - 채팅방: {}, 사용자: {}", chatRoomId, memberId); - - List updates = - subscriptionReadProcessingService.processUnreadMessagesOnSubscribe(chatRoomId, memberId); - - if (!updates.isEmpty()) { - broadcastUnreadCountUpdates(chatRoomId, updates, memberId); - log.info("구독으로 인한 안읽은 수 업데이트 브로드캐스트 완료 - 업데이트된 메시지 수: {}", updates.size()); - } - } - - public void unsubscribeToChatRoom(Long chatRoomId, Long memberId) { - redisSubscriptionService.removeSubscriber(chatRoomId, memberId); - log.info("채팅방 구독 해제 완료 - 채팅방: {}, 사용자: {}", chatRoomId, memberId); - } - - public void broadcastMessage(Long chatRoomId, WebSocketMessageDTO.MessageResponse message, Long senderId) { - broadcastToChatRoom(chatRoomId, message, senderId); - } - - public void broadcastSystemMessage(Long chatRoomId, WebSocketMessageDTO.MessageResponse message) { - broadcastToChatRoom(chatRoomId, message, null); - } - - public List getActiveSubscribers(Long chatRoomId) { - Set redisSubscribers = redisSubscriptionService.getSubscribers(chatRoomId); - - return redisSubscribers.stream() - .filter(memberId -> { - WebSocketSession session = memberSessions.get(memberId); - return session != null && session.isOpen(); - }) - .toList(); - } - - private void broadcastToChatRoom(Long chatRoomId, WebSocketMessageDTO.MessageResponse message, Long excludedMemberId) { - List subscribers = getActiveSubscribers(chatRoomId); - if (subscribers == null || subscribers.isEmpty()) { - log.info("채팅방 {}에 구독 중인 사용자가 없습니다.", chatRoomId); - return; - } - - String messageJson; - try { - messageJson = objectMapper.writeValueAsString(message); - } catch (Exception e) { - log.error("메시지를 JSON으로 변환하는데 실패했습니다", e); - return; - } - - List successMembers = new ArrayList<>(); - List failedMembers = new ArrayList<>(); - - // 메시지 브로드캐스트 - for (Long memberId : subscribers) { - if (memberId.equals(excludedMemberId)) { - continue; - } - - WebSocketSession session = memberSessions.get(memberId); - if (session != null && session.isOpen()) { - try { - synchronized (session) { - session.sendMessage(new TextMessage(messageJson)); - } - successMembers.add(memberId); - } catch (Exception e) { - log.error("메시지 전송 실패 - 사용자: {}", memberId, e); - failedMembers.add(memberId); - } - } else { - failedMembers.add(memberId); - } - } - - failedMembers.forEach(memberSessions::remove); - - log.info("브로드캐스트 완료 - 채팅방: {}, 성공: {}명, 실패: {}명", chatRoomId, successMembers.size(), failedMembers.size()); - } - - private void broadcastUnreadCountUpdates( - Long chatRoomId, List updates, Long excludedMemberId) { - List subscribers = getActiveSubscribers(chatRoomId); - if (subscribers == null || subscribers.isEmpty()) { - return; - } - - for (SubscriptionReadProcessingService.MessageUnreadUpdate update : updates) { - try { - WebSocketMessageDTO.UnreadCountUpdateMessage updateMessage = WebSocketMessageDTO.UnreadCountUpdateMessage.builder() - .type(WebSocketMessageType.UNREAD_COUNT_UPDATE) - .chatRoomId(chatRoomId) - .messageId(update.messageId()) - .newUnreadCount(update.newUnreadCount()) - .timestamp(LocalDateTime.now()) - .build(); - - String messageJson = objectMapper.writeValueAsString(updateMessage); - - int successCount = 0; - for (Long memberId : subscribers) { - if (memberId.equals(excludedMemberId)) { - continue; - } - - WebSocketSession session = memberSessions.get(memberId); - if (session != null && session.isOpen()) { - try { - synchronized (session) { - session.sendMessage(new TextMessage(messageJson)); - } - successCount++; - } catch (Exception e) { - log.error("안읽은 수 업데이트 브로드캐스트 실패 - 사용자: {}, 메시지: {}", - memberId, update.messageId(), e); - } - } - } - - log.debug("메시지 {} 안읽은 수 업데이트 브로드캐스트 완료 - 성공: {}명, 새 안읽은 수: {}", - update.messageId(), successCount, update.newUnreadCount()); - - } catch (Exception e) { - log.error("안읽은 수 업데이트 메시지 생성 실패 - 메시지: {}", update.messageId(), e); - } - } - } - - public void broadcastChatRoomListUpdateToMembers( - Long chatRoomId, - Map memberUpdateData) { - log.info("채팅방 목록 업데이트 개별 브로드캐스트 시작 - 채팅방: {}, 대상자: {}명", chatRoomId, memberUpdateData.size()); - - Set chatListSubscribers = chatListSubscriptionService.getChatListSubscribers(chatRoomId); - - int successCount = 0; - int failedCount = 0; - - for (Map.Entry entry : memberUpdateData.entrySet()) { - Long memberId = entry.getKey(); - - if (!chatListSubscribers.contains(memberId)) { - continue; - } - - ChatRoomListUpdateData updateData = entry.getValue(); - WebSocketSession session = memberSessions.get(memberId); - if (session != null && session.isOpen()) { - try { - WebSocketMessageDTO.ChatRoomListUpdate message = WebSocketMessageDTO.ChatRoomListUpdate.builder() - .type(WebSocketMessageType.CHAT_ROOM_LIST_UPDATE) - .chatRoomId(chatRoomId) - .lastMessage(updateData.lastMessage()) - .newUnreadCount(updateData.unreadCount()) - .timestamp(LocalDateTime.now()) - .build(); - - String messageJson = objectMapper.writeValueAsString(message); - - synchronized (session) { - session.sendMessage(new TextMessage(messageJson)); - } - successCount++; - - } catch (Exception e) { - log.error("채팅방 목록 업데이트 전송 실패 - 사용자: {}", memberId, e); - failedCount++; - memberSessions.remove(memberId); - } - } else { - failedCount++; - } - } - - log.info("채팅방 목록 업데이트 개별 브로드캐스트 완료 - 성공: {}명, 실패: {}명", successCount, failedCount); - } - - @Builder - public record ChatRoomListUpdateData( - LastMessageUpdate lastMessage, - int unreadCount - ) { - } -} diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/websocket/UnreadCountUpdate.java b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/UnreadCountUpdate.java new file mode 100644 index 000000000..defe28f0c --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/UnreadCountUpdate.java @@ -0,0 +1,7 @@ +package umc.cockple.demo.domain.chat.service.websocket; + +public record UnreadCountUpdate( + Long messageId, + int newUnreadCount +) { +} diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/websocket/broadcast/ChatRoomListUpdateBroadcaster.java b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/broadcast/ChatRoomListUpdateBroadcaster.java new file mode 100644 index 000000000..6eef2d4b8 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/broadcast/ChatRoomListUpdateBroadcaster.java @@ -0,0 +1,61 @@ +package umc.cockple.demo.domain.chat.service.websocket.broadcast; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import umc.cockple.demo.domain.chat.dto.WebSocketMessageDTO; +import umc.cockple.demo.domain.chat.enums.WebSocketMessageType; +import umc.cockple.demo.domain.chat.repository.redis.ChatListSubscriptionStore; +import umc.cockple.demo.domain.chat.service.websocket.session.ChatMessageEncoder; +import umc.cockple.demo.domain.chat.service.websocket.session.ChatMessageSender; +import umc.cockple.demo.domain.chat.service.websocket.session.EncodedChatMessage; + +import java.time.LocalDateTime; +import java.util.Map; +import java.util.Set; + +@Component +@RequiredArgsConstructor +@Slf4j +public class ChatRoomListUpdateBroadcaster { + + private final ChatListSubscriptionStore chatListSubscriptionStore; + private final ChatMessageEncoder messageEncoder; + private final ChatMessageSender messageSender; + + public void broadcast(Long chatRoomId, Map memberUpdateData) { + log.info("채팅방 목록 업데이트 개별 브로드캐스트 시작 - 채팅방: {}, 대상자: {}명", chatRoomId, memberUpdateData.size()); + + Set chatListSubscribers = chatListSubscriptionStore.getChatListSubscribers(chatRoomId); + + int successCount = 0; + int failedCount = 0; + + for (Map.Entry entry : memberUpdateData.entrySet()) { + Long memberId = entry.getKey(); + + if (!chatListSubscribers.contains(memberId)) { + continue; + } + + ChatRoomListUpdateData updateData = entry.getValue(); + WebSocketMessageDTO.ChatRoomListUpdate message = WebSocketMessageDTO.ChatRoomListUpdate.builder() + .type(WebSocketMessageType.CHAT_ROOM_LIST_UPDATE) + .chatRoomId(chatRoomId) + .lastMessage(updateData.lastMessage()) + .newUnreadCount(updateData.unreadCount()) + .timestamp(LocalDateTime.now()) + .build(); + + EncodedChatMessage encodedMessage = messageEncoder.encode(message).orElse(null); + if (encodedMessage != null && messageSender.send(memberId, encodedMessage)) { + successCount++; + } else { + log.error("채팅방 목록 업데이트 전송 실패 - 사용자: {}", memberId); + failedCount++; + } + } + + log.info("채팅방 목록 업데이트 개별 브로드캐스트 완료 - 성공: {}명, 실패: {}명", successCount, failedCount); + } +} diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/websocket/broadcast/ChatRoomListUpdateData.java b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/broadcast/ChatRoomListUpdateData.java new file mode 100644 index 000000000..ffed11053 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/broadcast/ChatRoomListUpdateData.java @@ -0,0 +1,11 @@ +package umc.cockple.demo.domain.chat.service.websocket.broadcast; + +import lombok.Builder; +import umc.cockple.demo.domain.chat.dto.WebSocketMessageDTO.ChatRoomListUpdate.LastMessageUpdate; + +@Builder +public record ChatRoomListUpdateData( + LastMessageUpdate lastMessage, + int unreadCount +) { +} diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/websocket/broadcast/ChatRoomMessageBroadcaster.java b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/broadcast/ChatRoomMessageBroadcaster.java new file mode 100644 index 000000000..5254a84e6 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/broadcast/ChatRoomMessageBroadcaster.java @@ -0,0 +1,54 @@ +package umc.cockple.demo.domain.chat.service.websocket.broadcast; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import umc.cockple.demo.domain.chat.dto.WebSocketMessageDTO; +import umc.cockple.demo.domain.chat.service.websocket.session.ChatMessageEncoder; +import umc.cockple.demo.domain.chat.service.websocket.session.ChatMessageSender; +import umc.cockple.demo.domain.chat.service.websocket.session.EncodedChatMessage; + +import java.util.ArrayList; +import java.util.List; + +@Component +@RequiredArgsConstructor +@Slf4j +public class ChatRoomMessageBroadcaster { + + private final ChatMessageEncoder messageEncoder; + private final ChatMessageSender messageSender; + + public void broadcast( + Long chatRoomId, + WebSocketMessageDTO.MessageResponse message, + List subscribers, + Long excludedMemberId) { + if (subscribers == null || subscribers.isEmpty()) { + log.info("채팅방 {}에 구독 중인 사용자가 없습니다.", chatRoomId); + return; + } + + EncodedChatMessage encodedMessage = messageEncoder.encode(message).orElse(null); + if (encodedMessage == null) { + return; + } + + List successMembers = new ArrayList<>(); + List failedMembers = new ArrayList<>(); + + for (Long memberId : subscribers) { + if (memberId.equals(excludedMemberId)) { + continue; + } + + if (messageSender.send(memberId, encodedMessage)) { + successMembers.add(memberId); + } else { + failedMembers.add(memberId); + } + } + + log.info("브로드캐스트 완료 - 채팅방: {}, 성공: {}명, 실패: {}명", chatRoomId, successMembers.size(), failedMembers.size()); + } +} diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/websocket/broadcast/UnreadCountUpdateBroadcaster.java b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/broadcast/UnreadCountUpdateBroadcaster.java new file mode 100644 index 000000000..1a1747017 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/broadcast/UnreadCountUpdateBroadcaster.java @@ -0,0 +1,66 @@ +package umc.cockple.demo.domain.chat.service.websocket.broadcast; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import umc.cockple.demo.domain.chat.dto.WebSocketMessageDTO; +import umc.cockple.demo.domain.chat.enums.WebSocketMessageType; +import umc.cockple.demo.domain.chat.service.websocket.UnreadCountUpdate; +import umc.cockple.demo.domain.chat.service.websocket.session.ChatMessageEncoder; +import umc.cockple.demo.domain.chat.service.websocket.session.ChatMessageSender; +import umc.cockple.demo.domain.chat.service.websocket.session.EncodedChatMessage; + +import java.time.LocalDateTime; +import java.util.List; + +@Component +@RequiredArgsConstructor +@Slf4j +public class UnreadCountUpdateBroadcaster { + + private final ChatMessageEncoder messageEncoder; + private final ChatMessageSender messageSender; + + public void broadcast( + Long chatRoomId, + List updates, + List subscribers, + Long excludedMemberId) { + if (subscribers == null || subscribers.isEmpty()) { + return; + } + + for (UnreadCountUpdate update : updates) { + WebSocketMessageDTO.UnreadCountUpdateMessage updateMessage = WebSocketMessageDTO.UnreadCountUpdateMessage.builder() + .type(WebSocketMessageType.UNREAD_COUNT_UPDATE) + .chatRoomId(chatRoomId) + .messageId(update.messageId()) + .newUnreadCount(update.newUnreadCount()) + .timestamp(LocalDateTime.now()) + .build(); + + EncodedChatMessage encodedMessage = messageEncoder.encode(updateMessage).orElse(null); + if (encodedMessage == null) { + log.error("안읽은 수 업데이트 메시지 생성 실패 - 메시지: {}", update.messageId()); + continue; + } + + int successCount = 0; + for (Long memberId : subscribers) { + if (memberId.equals(excludedMemberId)) { + continue; + } + + if (messageSender.send(memberId, encodedMessage)) { + successCount++; + } else { + log.error("안읽은 수 업데이트 브로드캐스트 실패 - 사용자: {}, 메시지: {}", + memberId, update.messageId()); + } + } + + log.debug("메시지 {} 안읽은 수 업데이트 브로드캐스트 완료 - 성공: {}명, 새 안읽은 수: {}", + update.messageId(), successCount, update.newUnreadCount()); + } + } +} diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/websocket/send/ChatSendService.java b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/send/ChatSendService.java new file mode 100644 index 000000000..32663dd1f --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/send/ChatSendService.java @@ -0,0 +1,121 @@ +package umc.cockple.demo.domain.chat.service.websocket.send; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import umc.cockple.demo.domain.chat.converter.ChatWebSocketResponseAssembler; +import umc.cockple.demo.domain.chat.domain.*; +import umc.cockple.demo.domain.chat.dto.ChatCommonDTO; +import umc.cockple.demo.domain.chat.dto.WebSocketMessageDTO; +import umc.cockple.demo.domain.chat.enums.MessageType; +import umc.cockple.demo.domain.chat.repository.ChatMessageRepository; +import umc.cockple.demo.domain.chat.service.ChatProcessor; +import umc.cockple.demo.domain.chat.service.websocket.send.support.ChatMessageFileAppender; +import umc.cockple.demo.domain.chat.service.websocket.send.support.ChatSendEventPublisher; +import umc.cockple.demo.domain.chat.service.websocket.send.support.DirectChatRoomActivationService; +import umc.cockple.demo.domain.chat.service.support.reader.ChatMemberReader; +import umc.cockple.demo.domain.chat.service.support.reader.ChatRoomReader; +import umc.cockple.demo.domain.chat.service.websocket.send.support.SentMessageReadStatusService; +import umc.cockple.demo.domain.chat.service.websocket.send.support.MessageReadCreationService; +import umc.cockple.demo.domain.chat.service.websocket.broadcast.ChatRoomMessageBroadcaster; +import umc.cockple.demo.domain.chat.service.websocket.subscription.support.ActiveChatRoomSubscriberReader; +import umc.cockple.demo.domain.member.domain.Member; + +import java.util.List; + +@Service +@Transactional +@RequiredArgsConstructor +@Slf4j +public class ChatSendService { + + private final ChatMessageRepository chatMessageRepository; + + private final ChatRoomReader chatRoomReader; + private final ChatMemberReader chatMemberReader; + private final ChatMessageFileAppender chatMessageFileAppender; + private final DirectChatRoomActivationService directChatRoomActivationService; + private final ChatSendEventPublisher chatSendEventPublisher; + private final ActiveChatRoomSubscriberReader activeChatRoomSubscriberReader; + private final ChatRoomMessageBroadcaster chatRoomMessageBroadcaster; + private final MessageReadCreationService messageReadCreationService; + private final ChatProcessor chatProcessor; + private final ChatWebSocketResponseAssembler chatWebSocketResponseAssembler; + private final SentMessageReadStatusService sentMessageReadStatusService; + + public void sendMessage(Long chatRoomId, String content, List files, Long senderId) { + log.info("메시지 전송 시작 - 채팅방: {}, 발신자: {}", chatRoomId, senderId); + + ChatRoom chatRoom = chatRoomReader.read(chatRoomId); + Member sender = chatMemberReader.readWithProfile(senderId); + + String profileImageUrl = chatProcessor.generateProfileImageUrl(sender.getProfileImg()); + + ChatMessage chatMessage = ChatMessage.create(chatRoom, sender, content, MessageType.TEXT); + chatMessageFileAppender.append(chatMessage, files); + ChatMessage savedMessage = chatMessageRepository.save(chatMessage); + log.info("메시지 저장 완료 - 메시지 ID: {}", savedMessage.getId()); + + directChatRoomActivationService.joinPendingMemberOnFirstMessage(chatRoom, senderId); + messageReadCreationService.createReadStatusForNewMessage(savedMessage, senderId); + + List activeSubscribers = activeChatRoomSubscriberReader.findActiveSubscribers(chatRoomId); + int unreadCount = sentMessageReadStatusService.markActiveSubscribersAsRead(chatRoom.getId(), savedMessage.getId(), activeSubscribers, senderId); + + List responseFiles = + createResponseFileInfos(savedMessage.getChatMessageFiles()); + + log.info("메시지 브로드캐스트 시작 - 채팅방 ID: {}", chatRoomId); + WebSocketMessageDTO.MessageResponse response = + chatWebSocketResponseAssembler.toSendMessageResponse(chatRoomId, content, responseFiles, savedMessage, sender, profileImageUrl, unreadCount); + chatRoomMessageBroadcaster.broadcast(chatRoomId, response, activeSubscribers, senderId); + log.info("메시지 브로드캐스트 완료 - 채팅방 ID: {}", chatRoomId); + + chatSendEventPublisher.publishChatNotificationEvent(chatRoom, savedMessage, sender, activeSubscribers); + chatSendEventPublisher.publishChatRoomListUpdateEvent(chatRoom, savedMessage); + chatSendEventPublisher.publishUnreadStatusUpdateEvent(chatRoom, senderId); + } + + public void sendSystemMessage(Long partyId, String content) { + ChatRoom chatRoom = chatRoomReader.readByPartyId(partyId); + + ChatMessage systemMessage = ChatMessage.create(chatRoom, null, content, MessageType.SYSTEM); + ChatMessage savedSystemMessage = chatMessageRepository.save(systemMessage); + + messageReadCreationService.createReadStatusForNewMessage(savedSystemMessage, null); + + List activeSubscribers = activeChatRoomSubscriberReader.findActiveSubscribers(chatRoom.getId()); + sentMessageReadStatusService.markActiveSubscribersAsRead( + chatRoom.getId(), + savedSystemMessage.getId(), + activeSubscribers, + null + ); + + WebSocketMessageDTO.MessageResponse broadcastSystemMessage + = chatWebSocketResponseAssembler.toSystemMessageResponse(chatRoom.getId(), content, savedSystemMessage); + + chatRoomMessageBroadcaster.broadcast(chatRoom.getId(), broadcastSystemMessage, activeSubscribers, null); + chatSendEventPublisher.publishChatRoomListUpdateEvent(chatRoom, savedSystemMessage); + chatSendEventPublisher.publishUnreadStatusUpdateEvent(chatRoom, null); + log.info("시스템 메시지 브로드캐스트 완료 - chatRoomId: {}", chatRoom.getId()); + } + + // ========== 비즈니스 메서드 ========== + private List createResponseFileInfos( + List savedFiles) { + return savedFiles.stream() + .map(file -> ChatCommonDTO.FileInfo.builder() + .imageId(file.getId()) + .imageUrl(chatProcessor.generateFileUrl(file)) + .imgOrder(file.getFileOrder()) + .isEmoji(file.getIsEmoji()) + .originalFileName(file.getOriginalFileName()) + .fileSize(file.getFileSize()) + .fileType(file.getFileType()) + .build()) + .toList(); + } + +} diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/websocket/send/support/ChatMessageFileAppender.java b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/send/support/ChatMessageFileAppender.java new file mode 100644 index 000000000..d78e1a496 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/send/support/ChatMessageFileAppender.java @@ -0,0 +1,30 @@ +package umc.cockple.demo.domain.chat.service.websocket.send.support; + +import org.springframework.stereotype.Component; +import umc.cockple.demo.domain.chat.domain.ChatMessage; +import umc.cockple.demo.domain.chat.domain.ChatMessageFile; +import umc.cockple.demo.domain.chat.dto.WebSocketMessageDTO; + +import java.util.List; + +@Component +public class ChatMessageFileAppender { + + public void append(ChatMessage message, List files) { + if (files == null || files.isEmpty()) { + return; + } + + files.forEach(fileInfo -> { + ChatMessageFile messageFile = ChatMessageFile.create( + message, + fileInfo.imgKey(), + fileInfo.imgOrder(), + fileInfo.originalFileName(), + fileInfo.fileSize(), + fileInfo.fileType() + ); + message.getChatMessageFiles().add(messageFile); + }); + } +} diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/websocket/send/support/ChatSendEventPublisher.java b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/send/support/ChatSendEventPublisher.java new file mode 100644 index 000000000..2a54d0c61 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/send/support/ChatSendEventPublisher.java @@ -0,0 +1,93 @@ +package umc.cockple.demo.domain.chat.service.websocket.send.support; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; +import umc.cockple.demo.domain.chat.domain.ChatMessage; +import umc.cockple.demo.domain.chat.domain.ChatRoom; +import umc.cockple.demo.domain.chat.enums.ChatRoomType; +import umc.cockple.demo.domain.chat.events.ChatRoomListUpdateEvent; +import umc.cockple.demo.domain.chat.events.ChatUnreadStatusUpdateEvent; +import umc.cockple.demo.domain.chat.repository.ChatRoomMemberRepository; +import umc.cockple.demo.domain.chat.service.ChatUnreadQueryService; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.notification.events.ChatNotificationEvent; + +import java.util.List; +import java.util.Map; + +@Component +@RequiredArgsConstructor +@Slf4j +public class ChatSendEventPublisher { + + private final ChatRoomMemberRepository chatRoomMemberRepository; + private final ChatUnreadQueryService chatUnreadQueryService; + private final ApplicationEventPublisher eventPublisher; + + public void publishChatNotificationEvent( + ChatRoom chatRoom, + ChatMessage savedMessage, + Member sender, + List activeSubscribers) { + String notificationTitle = chatRoom.getType() == ChatRoomType.PARTY + ? chatRoom.getParty().getPartyName() + : sender.getNickname(); + String notificationContent = chatRoom.getType() == ChatRoomType.PARTY + ? sender.getNickname() + ": " + savedMessage.getDisplayContent() + : savedMessage.getDisplayContent(); + + eventPublisher.publishEvent(ChatNotificationEvent.create( + chatRoom.getId(), + chatRoom.getType(), + notificationTitle, + notificationContent, + sender.getId(), + activeSubscribers + )); + } + + public void publishChatRoomListUpdateEvent(ChatRoom chatRoom, ChatMessage savedMessage) { + try { + List chatRoomMemberIds = chatRoomMemberRepository.findMemberIdsByChatRoomId(chatRoom.getId()); + + Map memberUnreadCounts = chatUnreadQueryService.countUnreadMessagesByMembers( + chatRoom.getId(), chatRoomMemberIds); + + ChatRoomListUpdateEvent listUpdateEvent = ChatRoomListUpdateEvent.create( + chatRoom.getId(), + savedMessage.getDisplayContent(), + savedMessage.getCreatedAt(), + savedMessage.getType().name(), + memberUnreadCounts + ); + + eventPublisher.publishEvent(listUpdateEvent); + log.info("채팅방 목록 업데이트 이벤트 발행 - 채팅방: {}", chatRoom.getId()); + + } catch (Exception e) { + log.error("채팅방 목록 업데이트 이벤트 발행 실패 - 채팅방: {}", chatRoom.getId(), e); + } + } + + public void publishUnreadStatusUpdateEvent(ChatRoom chatRoom, Long senderId) { + try { + List targetMemberIds = chatRoomMemberRepository.findMemberIdsByChatRoomId(chatRoom.getId()).stream() + .filter(memberId -> senderId == null || !memberId.equals(senderId)) + .distinct() + .toList(); + + if (targetMemberIds.isEmpty()) { + log.debug("안읽음 상태 업데이트 대상 없음 - 채팅방: {}", chatRoom.getId()); + return; + } + + eventPublisher.publishEvent(ChatUnreadStatusUpdateEvent.of(targetMemberIds)); + log.info("안읽음 상태 업데이트 이벤트 발행 - 채팅방: {}, 대상자: {}명", + chatRoom.getId(), targetMemberIds.size()); + } catch (Exception e) { + log.error("안읽음 상태 업데이트 이벤트 발행 실패 - 채팅방: {}", chatRoom.getId(), e); + } + } +} diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/websocket/send/support/DirectChatRoomActivationService.java b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/send/support/DirectChatRoomActivationService.java new file mode 100644 index 000000000..79ea4ffbf --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/send/support/DirectChatRoomActivationService.java @@ -0,0 +1,40 @@ +package umc.cockple.demo.domain.chat.service.websocket.send.support; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import umc.cockple.demo.domain.chat.domain.ChatRoom; +import umc.cockple.demo.domain.chat.domain.ChatRoomMember; +import umc.cockple.demo.domain.chat.enums.ChatRoomType; +import umc.cockple.demo.domain.chat.repository.ChatMessageRepository; +import umc.cockple.demo.domain.chat.repository.ChatRoomMemberRepository; + +@Service +@Transactional +@RequiredArgsConstructor +@Slf4j +public class DirectChatRoomActivationService { + + private final ChatMessageRepository chatMessageRepository; + private final ChatRoomMemberRepository chatRoomMemberRepository; + + public void joinPendingMemberOnFirstMessage(ChatRoom chatRoom, Long senderId) { + if (chatRoom.getType() != ChatRoomType.DIRECT || !isFirstMessage(chatRoom.getId())) { + return; + } + + log.info("첫 번째 개인 메시지 처리 - 채팅방: {}", chatRoom.getId()); + chatRoomMemberRepository.findPendingMemberInDirect(chatRoom.getId(), senderId) + .ifPresent(this::joinPendingMember); + } + + private boolean isFirstMessage(Long chatRoomId) { + return chatMessageRepository.countByChatRoomId(chatRoomId) == 1; + } + + private void joinPendingMember(ChatRoomMember pendingMember) { + pendingMember.joinChatRoom(); + log.info("PENDING 멤버를 JOINED로 변경 완료 - 멤버 ID: {}", pendingMember.getMember().getId()); + } +} diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/websocket/MessageReadCreationService.java b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/send/support/MessageReadCreationService.java similarity index 96% rename from src/main/java/umc/cockple/demo/domain/chat/service/websocket/MessageReadCreationService.java rename to src/main/java/umc/cockple/demo/domain/chat/service/websocket/send/support/MessageReadCreationService.java index 1698a36d7..8865c7baa 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/service/websocket/MessageReadCreationService.java +++ b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/send/support/MessageReadCreationService.java @@ -1,4 +1,4 @@ -package umc.cockple.demo.domain.chat.service.websocket; +package umc.cockple.demo.domain.chat.service.websocket.send.support; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/websocket/send/support/SentMessageReadStatusService.java b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/send/support/SentMessageReadStatusService.java new file mode 100644 index 000000000..9d9529d4a --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/send/support/SentMessageReadStatusService.java @@ -0,0 +1,45 @@ +package umc.cockple.demo.domain.chat.service.websocket.send.support; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import umc.cockple.demo.domain.chat.service.support.reader.ReadStatusReader; +import umc.cockple.demo.domain.chat.service.support.updater.ChatMemberReadStateUpdater; +import umc.cockple.demo.domain.chat.service.support.updater.ReadStatusUpdater; + +import java.util.List; + +@Service +@Transactional +@RequiredArgsConstructor +@Slf4j +public class SentMessageReadStatusService { + + private final ReadStatusReader readStatusReader; + private final ReadStatusUpdater readStatusUpdater; + private final ChatMemberReadStateUpdater chatMemberReadStateUpdater; + + public int markActiveSubscribersAsRead(Long chatRoomId, Long messageId, List activeSubscribers, Long senderId) { + log.info("초기 읽음 처리 - 메시지: {}, 활성 구독자 수: {}, 발신자: {}", + messageId, activeSubscribers.size(), senderId); + + List readers = activeSubscribers.stream() + .filter(memberId -> !memberId.equals(senderId)) + .toList(); + + if (!readers.isEmpty()) { + int updatedCount = readStatusUpdater.markMessageAsReadForMembers(messageId, readers); + log.info("초기 읽음 처리 완료 - 처리된 구독자: {}명", updatedCount); + + int lastReadUpdatedCount = chatMemberReadStateUpdater.advanceLastReadMessageIdForMembers( + chatRoomId, readers, messageId); + log.debug("활성 구독자 lastReadMessageId 배치 업데이트 완료 - 처리된 멤버: {}명", lastReadUpdatedCount); + } + + int finalUnreadCount = readStatusReader.countUnreadByMessageId(messageId); + log.info("초기 처리 후 최종 안읽은 수: {}", finalUnreadCount); + + return finalUnreadCount; + } +} diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/websocket/session/ChatMessageEncoder.java b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/session/ChatMessageEncoder.java new file mode 100644 index 000000000..8a6b2e6e4 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/session/ChatMessageEncoder.java @@ -0,0 +1,8 @@ +package umc.cockple.demo.domain.chat.service.websocket.session; + +import java.util.Optional; + +public interface ChatMessageEncoder { + + Optional encode(Object message); +} diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/websocket/session/ChatMessageSender.java b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/session/ChatMessageSender.java new file mode 100644 index 000000000..ce56955db --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/session/ChatMessageSender.java @@ -0,0 +1,6 @@ +package umc.cockple.demo.domain.chat.service.websocket.session; + +public interface ChatMessageSender { + + boolean send(Long memberId, EncodedChatMessage message); +} diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/websocket/session/ChatSessionRegistry.java b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/session/ChatSessionRegistry.java new file mode 100644 index 000000000..cf633a772 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/session/ChatSessionRegistry.java @@ -0,0 +1,9 @@ +package umc.cockple.demo.domain.chat.service.websocket.session; + +import java.util.Collection; +import java.util.List; + +public interface ChatSessionRegistry { + + List findOpenMemberIds(Collection memberIds); +} diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/websocket/session/EncodedChatMessage.java b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/session/EncodedChatMessage.java new file mode 100644 index 000000000..5d8ae2903 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/session/EncodedChatMessage.java @@ -0,0 +1,4 @@ +package umc.cockple.demo.domain.chat.service.websocket.session; + +public record EncodedChatMessage(String payload) { +} diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/websocket/subscription/ChatRoomSubscriptionService.java b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/subscription/ChatRoomSubscriptionService.java new file mode 100644 index 000000000..3c2515ec1 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/subscription/ChatRoomSubscriptionService.java @@ -0,0 +1,49 @@ +package umc.cockple.demo.domain.chat.service.websocket.subscription; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import umc.cockple.demo.domain.chat.repository.redis.ChatRoomSubscriptionStore; +import umc.cockple.demo.domain.chat.service.websocket.UnreadCountUpdate; +import umc.cockple.demo.domain.chat.service.websocket.broadcast.UnreadCountUpdateBroadcaster; +import umc.cockple.demo.domain.chat.service.websocket.subscription.support.ActiveChatRoomSubscriberReader; +import umc.cockple.demo.domain.chat.service.websocket.subscription.support.SubscribeReadStatusService; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ChatRoomSubscriptionService { + + /* + * 이 서비스는 이미 접수된 WebSocket 구독 명령의 후속 side effect를 수행한다. + * + * 일관성 수준은 best-effort다. 읽음 처리나 unread-count 브로드캐스트가 실패해도 listener가 실패를 error log로 + * 남기고 socket ACK 경로로 전파하지 않는다. Redis 구독 상태 변경을 먼저 시도하며, 현재 계약에는 재시도/보상이 없다. + */ + + private final SubscribeReadStatusService subscribeReadStatusService; + private final ChatRoomSubscriptionStore chatRoomSubscriptionStore; + private final UnreadCountUpdateBroadcaster unreadCountUpdateBroadcaster; + private final ActiveChatRoomSubscriberReader activeChatRoomSubscriberReader; + + public void subscribeToChatRoom(Long chatRoomId, Long memberId) { + chatRoomSubscriptionStore.addSubscriber(chatRoomId, memberId); + log.info("채팅방 구독 - 채팅방: {}, 사용자: {}", chatRoomId, memberId); + + List updates = + subscribeReadStatusService.markUnreadMessagesAsReadOnSubscribe(chatRoomId, memberId); + + if (!updates.isEmpty()) { + List subscribers = activeChatRoomSubscriberReader.findActiveSubscribers(chatRoomId); + unreadCountUpdateBroadcaster.broadcast(chatRoomId, updates, subscribers, memberId); + log.info("구독으로 인한 안읽은 수 업데이트 브로드캐스트 완료 - 업데이트된 메시지 수: {}", updates.size()); + } + } + + public void unsubscribeToChatRoom(Long chatRoomId, Long memberId) { + chatRoomSubscriptionStore.removeSubscriber(chatRoomId, memberId); + log.info("채팅방 구독 해제 완료 - 채팅방: {}, 사용자: {}", chatRoomId, memberId); + } +} diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/websocket/subscription/support/ActiveChatRoomSubscriberReader.java b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/subscription/support/ActiveChatRoomSubscriberReader.java new file mode 100644 index 000000000..18eb966f6 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/subscription/support/ActiveChatRoomSubscriberReader.java @@ -0,0 +1,22 @@ +package umc.cockple.demo.domain.chat.service.websocket.subscription.support; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import umc.cockple.demo.domain.chat.repository.redis.ChatRoomSubscriptionStore; +import umc.cockple.demo.domain.chat.service.websocket.session.ChatSessionRegistry; + +import java.util.List; +import java.util.Set; + +@Component +@RequiredArgsConstructor +public class ActiveChatRoomSubscriberReader { + + private final ChatRoomSubscriptionStore chatRoomSubscriptionStore; + private final ChatSessionRegistry sessionRegistry; + + public List findActiveSubscribers(Long chatRoomId) { + Set subscribedMemberIds = chatRoomSubscriptionStore.getSubscribers(chatRoomId); + return sessionRegistry.findOpenMemberIds(subscribedMemberIds); + } +} diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/websocket/subscription/support/SubscribeReadStatusService.java b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/subscription/support/SubscribeReadStatusService.java new file mode 100644 index 000000000..92f61bc9a --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/subscription/support/SubscribeReadStatusService.java @@ -0,0 +1,64 @@ +package umc.cockple.demo.domain.chat.service.websocket.subscription.support; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import umc.cockple.demo.domain.chat.events.ChatUnreadStatusUpdateEvent; +import umc.cockple.demo.domain.chat.service.support.reader.ReadStatusReader; +import umc.cockple.demo.domain.chat.service.support.updater.ChatMemberReadStateUpdater; +import umc.cockple.demo.domain.chat.service.support.updater.ReadStatusUpdater; +import umc.cockple.demo.domain.chat.service.websocket.UnreadCountUpdate; + +import java.util.List; +import java.util.Map; + +@Service +@Transactional +@RequiredArgsConstructor +@Slf4j +public class SubscribeReadStatusService { + + private final ReadStatusReader readStatusReader; + private final ReadStatusUpdater readStatusUpdater; + private final ChatMemberReadStateUpdater chatMemberReadStateUpdater; + private final ApplicationEventPublisher eventPublisher; + + public List markUnreadMessagesAsReadOnSubscribe(Long chatRoomId, Long memberId) { + List unreadMessageIds = readStatusReader.findUnreadMessageIds(chatRoomId, memberId); + if (unreadMessageIds.isEmpty()) { + return List.of(); + } + + readStatusUpdater.markMessagesAsReadForMember(chatRoomId, memberId, unreadMessageIds); + + List updates = createUnreadUpdates(unreadMessageIds); + updateLastReadMessageId(chatRoomId, memberId, latestMessageId(unreadMessageIds)); + eventPublisher.publishEvent(ChatUnreadStatusUpdateEvent.of(List.of(memberId))); + + return updates; + } + + private List createUnreadUpdates(List unreadMessageIds) { + Map unreadCounts = readStatusReader.countUnreadByMessageIdsAsSparseMap(unreadMessageIds); + + return unreadMessageIds.stream() + .map(messageId -> new UnreadCountUpdate(messageId, unreadCounts.getOrDefault(messageId, 0))) + .toList(); + } + + private Long latestMessageId(List messageIds) { + return messageIds.stream() + .max(Long::compareTo) + .orElseThrow(); + } + + private void updateLastReadMessageId(Long chatRoomId, Long memberId, Long messageId) { + try { + chatMemberReadStateUpdater.advanceLastReadMessageId(chatRoomId, memberId, messageId); + } catch (Exception e) { + log.error("구독 시 lastReadMessageId 업데이트 실패 - 멤버: {}, 메시지: {}", memberId, messageId, e); + } + } +} diff --git a/src/main/java/umc/cockple/demo/domain/contest/repository/ContestRepository.java b/src/main/java/umc/cockple/demo/domain/contest/repository/ContestRepository.java index b366196bf..13537a352 100644 --- a/src/main/java/umc/cockple/demo/domain/contest/repository/ContestRepository.java +++ b/src/main/java/umc/cockple/demo/domain/contest/repository/ContestRepository.java @@ -4,7 +4,6 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import umc.cockple.demo.domain.contest.domain.Contest; - import java.util.List; import java.util.Optional; @@ -13,16 +12,17 @@ public interface ContestRepository extends JpaRepository { List findAllByMember_Id(Long memberId); - @Query("SELECT COUNT(c) FROM Contest c WHERE c.member.id = :memberId") - int countAllMedalsByMemberId(@Param("memberId") Long memberId); - - @Query("SELECT COUNT(c) FROM Contest c WHERE c.member.id = :memberId AND c.medalType = 'GOLD'") - int countGoldMedalsByMemberId(@Param("memberId") Long memberId); - - @Query("SELECT COUNT(c) FROM Contest c WHERE c.member.id = :memberId AND c.medalType = 'SILVER'") - int countSilverMedalsByMemberId(@Param("memberId") Long memberId); - - @Query("SELECT COUNT(c) FROM Contest c WHERE c.member.id = :memberId AND c.medalType = 'BRONZE'") - int countBronzeMedalsByMemberId(@Param("memberId") Long memberId); + /** + * 회원의 메달을 종류별로 한 번의 조건부 집계 쿼리 카운트 + */ + @Query(value = """ + SELECT + COUNT(CASE WHEN c.medal_type = 'GOLD' THEN 1 END) AS gold, + COUNT(CASE WHEN c.medal_type = 'SILVER' THEN 1 END) AS silver, + COUNT(CASE WHEN c.medal_type = 'BRONZE' THEN 1 END) AS bronze + FROM contest c + WHERE c.member_id = :memberId + """, nativeQuery = true) + MedalCountProjection countMedalsByMemberId(@Param("memberId") Long memberId); } diff --git a/src/main/java/umc/cockple/demo/domain/contest/repository/MedalCountProjection.java b/src/main/java/umc/cockple/demo/domain/contest/repository/MedalCountProjection.java new file mode 100644 index 000000000..6605dc58a --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/contest/repository/MedalCountProjection.java @@ -0,0 +1,9 @@ +package umc.cockple.demo.domain.contest.repository; + +public interface MedalCountProjection { + long getGold(); + + long getSilver(); + + long getBronze(); +} diff --git a/src/main/java/umc/cockple/demo/domain/contest/service/ContestQueryServiceImpl.java b/src/main/java/umc/cockple/demo/domain/contest/service/ContestQueryServiceImpl.java index 332c05f06..a2a4418f3 100644 --- a/src/main/java/umc/cockple/demo/domain/contest/service/ContestQueryServiceImpl.java +++ b/src/main/java/umc/cockple/demo/domain/contest/service/ContestQueryServiceImpl.java @@ -12,6 +12,7 @@ import umc.cockple.demo.domain.contest.exception.ContestErrorCode; import umc.cockple.demo.domain.contest.exception.ContestException; import umc.cockple.demo.domain.contest.repository.ContestRepository; +import umc.cockple.demo.domain.contest.repository.MedalCountProjection; import umc.cockple.demo.domain.contest.enums.MedalType; import umc.cockple.demo.domain.file.service.FileService; @@ -85,9 +86,10 @@ public ContestMedalSummaryDTO.Response getMyMedalSummary(Long memberId) { log.info("[메달 개수 조회 시작] - memberId: {}", memberId); - int gold = contestRepository.countGoldMedalsByMemberId(memberId); - int silver = contestRepository.countSilverMedalsByMemberId(memberId); - int bronze = contestRepository.countBronzeMedalsByMemberId(memberId); + MedalCountProjection counts = contestRepository.countMedalsByMemberId(memberId); + int gold = (int) counts.getGold(); + int silver = (int) counts.getSilver(); + int bronze = (int) counts.getBronze(); log.info("[메달 조회 완료] - memberId: {}", memberId); diff --git a/src/main/java/umc/cockple/demo/domain/exercise/controller/ExerciseGuestController.java b/src/main/java/umc/cockple/demo/domain/exercise/controller/ExerciseGuestController.java index d44e7917b..9d7df4c63 100644 --- a/src/main/java/umc/cockple/demo/domain/exercise/controller/ExerciseGuestController.java +++ b/src/main/java/umc/cockple/demo/domain/exercise/controller/ExerciseGuestController.java @@ -8,7 +8,7 @@ import umc.cockple.demo.domain.exercise.dto.ExerciseCancelDTO; import umc.cockple.demo.domain.exercise.dto.ExerciseGuestInviteDTO; import umc.cockple.demo.domain.exercise.dto.ExerciseMyGuestListDTO; -import umc.cockple.demo.domain.exercise.service.ExerciseQueryService; +import umc.cockple.demo.domain.exercise.service.query.ExerciseGuestQueryService; import umc.cockple.demo.domain.exercise.service.command.ExerciseCommandService; import umc.cockple.demo.global.response.BaseResponse; import umc.cockple.demo.global.response.code.status.CommonSuccessCode; @@ -20,7 +20,7 @@ public class ExerciseGuestController implements ExerciseGuestApi { private final ExerciseCommandService exerciseCommandService; - private final ExerciseQueryService exerciseQueryService; + private final ExerciseGuestQueryService exerciseGuestQueryService; @Override public ResponseEntity> inviteGuest( @@ -48,7 +48,7 @@ public ResponseEntity> cancelGuestInvit public ResponseEntity> getMyInvitedGuests(Long exerciseId) { Long memberId = SecurityUtil.getCurrentMemberId(); - ExerciseMyGuestListDTO.Response response = exerciseQueryService.getMyInvitedGuests( + ExerciseMyGuestListDTO.Response response = exerciseGuestQueryService.getMyInvitedGuests( exerciseId, memberId); return BaseResponse.of(CommonSuccessCode.OK, response); diff --git a/src/main/java/umc/cockple/demo/domain/exercise/controller/ExerciseLifecycleController.java b/src/main/java/umc/cockple/demo/domain/exercise/controller/ExerciseLifecycleController.java index 470afe258..64472078d 100644 --- a/src/main/java/umc/cockple/demo/domain/exercise/controller/ExerciseLifecycleController.java +++ b/src/main/java/umc/cockple/demo/domain/exercise/controller/ExerciseLifecycleController.java @@ -10,7 +10,7 @@ import umc.cockple.demo.domain.exercise.dto.ExerciseDetailDTO; import umc.cockple.demo.domain.exercise.dto.ExerciseEditDetailDTO; import umc.cockple.demo.domain.exercise.dto.ExerciseUpdateDTO; -import umc.cockple.demo.domain.exercise.service.ExerciseQueryService; +import umc.cockple.demo.domain.exercise.service.query.ExerciseLifecycleQueryService; import umc.cockple.demo.domain.exercise.service.command.ExerciseCommandService; import umc.cockple.demo.global.response.BaseResponse; import umc.cockple.demo.global.response.code.status.CommonSuccessCode; @@ -22,7 +22,7 @@ public class ExerciseLifecycleController implements ExerciseLifecycleApi { private final ExerciseCommandService exerciseCommandService; - private final ExerciseQueryService exerciseQueryService; + private final ExerciseLifecycleQueryService exerciseLifecycleQueryService; @Override public ResponseEntity> createExercise( @@ -60,7 +60,7 @@ public ResponseEntity> updateExercise( public ResponseEntity> getExerciseDetail(Long exerciseId) { Long memberId = SecurityUtil.getCurrentMemberId(); - ExerciseDetailDTO.Response response = exerciseQueryService.getExerciseDetail( + ExerciseDetailDTO.Response response = exerciseLifecycleQueryService.getExerciseDetail( exerciseId, memberId); return BaseResponse.of(CommonSuccessCode.OK, response); @@ -69,7 +69,7 @@ public ResponseEntity> getExerciseDetai @Override public ResponseEntity> getExerciseForEdit(Long exerciseId) { Long memberId = SecurityUtil.getCurrentMemberId(); - ExerciseEditDetailDTO.Response response = exerciseQueryService.getExerciseForEdit(exerciseId, memberId); + ExerciseEditDetailDTO.Response response = exerciseLifecycleQueryService.getExerciseForEdit(exerciseId, memberId); return BaseResponse.of(CommonSuccessCode.OK, response); } } diff --git a/src/main/java/umc/cockple/demo/domain/exercise/controller/ExerciseMapController.java b/src/main/java/umc/cockple/demo/domain/exercise/controller/ExerciseMapController.java index e3da65a42..93465be9f 100644 --- a/src/main/java/umc/cockple/demo/domain/exercise/controller/ExerciseMapController.java +++ b/src/main/java/umc/cockple/demo/domain/exercise/controller/ExerciseMapController.java @@ -7,7 +7,7 @@ import umc.cockple.demo.domain.exercise.controller.api.ExerciseMapApi; import umc.cockple.demo.domain.exercise.dto.ExerciseBuildingDetailDTO; import umc.cockple.demo.domain.exercise.dto.ExerciseMapBuildingsDTO; -import umc.cockple.demo.domain.exercise.service.ExerciseQueryService; +import umc.cockple.demo.domain.exercise.service.query.ExerciseMapQueryService; import umc.cockple.demo.global.response.BaseResponse; import umc.cockple.demo.global.response.code.status.CommonSuccessCode; import umc.cockple.demo.global.security.utils.SecurityUtil; @@ -19,14 +19,14 @@ @Validated public class ExerciseMapController implements ExerciseMapApi { - private final ExerciseQueryService exerciseQueryService; + private final ExerciseMapQueryService exerciseMapQueryService; @Override public ResponseEntity> getBuildingExerciseDetails( LocalDate date, String buildingName, String streetAddr) { Long memberId = SecurityUtil.getCurrentMemberId(); - ExerciseBuildingDetailDTO.Response response = exerciseQueryService + ExerciseBuildingDetailDTO.Response response = exerciseMapQueryService .getBuildingExerciseDetails(buildingName, streetAddr, date, memberId); return BaseResponse.of(CommonSuccessCode.OK, response); @@ -38,7 +38,7 @@ public ResponseEntity> getMonthly Long memberId = SecurityUtil.getCurrentMemberId(); ExerciseMapBuildingsDTO.Query query = ExerciseMapBuildingsDTO.Query.of(date, latitude, longitude, radiusKm); - ExerciseMapBuildingsDTO.Response response = exerciseQueryService + ExerciseMapBuildingsDTO.Response response = exerciseMapQueryService .getExerciseMapCalendarSummary(query, memberId); return BaseResponse.of(CommonSuccessCode.OK, response); diff --git a/src/main/java/umc/cockple/demo/domain/exercise/controller/ExerciseMyController.java b/src/main/java/umc/cockple/demo/domain/exercise/controller/ExerciseMyController.java index be86bd9e9..edc48c197 100644 --- a/src/main/java/umc/cockple/demo/domain/exercise/controller/ExerciseMyController.java +++ b/src/main/java/umc/cockple/demo/domain/exercise/controller/ExerciseMyController.java @@ -13,7 +13,7 @@ import umc.cockple.demo.domain.exercise.enums.MyExerciseFilterType; import umc.cockple.demo.domain.exercise.enums.MyExerciseOrderType; import umc.cockple.demo.domain.exercise.enums.MyPartyExerciseOrderType; -import umc.cockple.demo.domain.exercise.service.ExerciseQueryService; +import umc.cockple.demo.domain.exercise.service.query.ExerciseMyQueryService; import umc.cockple.demo.global.response.BaseResponse; import umc.cockple.demo.global.response.code.status.CommonSuccessCode; import umc.cockple.demo.global.security.utils.SecurityUtil; @@ -25,14 +25,14 @@ @Validated public class ExerciseMyController implements ExerciseMyApi { - private final ExerciseQueryService exerciseQueryService; + private final ExerciseMyQueryService exerciseMyQueryService; @Override public ResponseEntity> getMyExerciseCalender( LocalDate startDate, LocalDate endDate) { Long memberId = SecurityUtil.getCurrentMemberId(); - MyExerciseCalendarDTO.Response response = exerciseQueryService.getMyExerciseCalendar( + MyExerciseCalendarDTO.Response response = exerciseMyQueryService.getMyExerciseCalendar( memberId, startDate, endDate); return BaseResponse.of(CommonSuccessCode.OK, response); @@ -42,7 +42,7 @@ public ResponseEntity> getMyExercis public ResponseEntity> getMyPartyExercise() { Long memberId = SecurityUtil.getCurrentMemberId(); - MyPartyExerciseDTO.Response response = exerciseQueryService.getMyPartyExercise(memberId); + MyPartyExerciseDTO.Response response = exerciseMyQueryService.getMyPartyExercise(memberId); return BaseResponse.of(CommonSuccessCode.OK, response); } @@ -52,7 +52,7 @@ public ResponseEntity> getMyPa MyPartyExerciseOrderType orderType, LocalDate startDate, LocalDate endDate) { Long memberId = SecurityUtil.getCurrentMemberId(); - MyPartyExerciseCalendarDTO.Response response = exerciseQueryService.getMyPartyExerciseCalendar( + MyPartyExerciseCalendarDTO.Response response = exerciseMyQueryService.getMyPartyExerciseCalendar( memberId, orderType, startDate, endDate); return BaseResponse.of(CommonSuccessCode.OK, response); @@ -63,7 +63,7 @@ public ResponseEntity> getMyExercises( MyExerciseFilterType filterType, MyExerciseOrderType orderType, Pageable pageable) { Long memberId = SecurityUtil.getCurrentMemberId(); - MyExerciseListDTO.Response response = exerciseQueryService.getMyExercises( + MyExerciseListDTO.Response response = exerciseMyQueryService.getMyExercises( memberId, filterType, orderType, pageable); return BaseResponse.of(CommonSuccessCode.OK, response); diff --git a/src/main/java/umc/cockple/demo/domain/exercise/controller/ExerciseRecommendationController.java b/src/main/java/umc/cockple/demo/domain/exercise/controller/ExerciseRecommendationController.java index b4635d1ad..fa07c0283 100644 --- a/src/main/java/umc/cockple/demo/domain/exercise/controller/ExerciseRecommendationController.java +++ b/src/main/java/umc/cockple/demo/domain/exercise/controller/ExerciseRecommendationController.java @@ -8,7 +8,7 @@ import umc.cockple.demo.domain.exercise.dto.ExerciseRecommendationCalendarDTO; import umc.cockple.demo.domain.exercise.dto.ExerciseRecommendationDTO; import umc.cockple.demo.domain.exercise.enums.MyPartyExerciseOrderType; -import umc.cockple.demo.domain.exercise.service.ExerciseQueryService; +import umc.cockple.demo.domain.exercise.service.query.ExerciseRecommendationQueryService; import umc.cockple.demo.domain.party.enums.ActivityTime; import umc.cockple.demo.domain.party.enums.ParticipationType; import umc.cockple.demo.global.enums.Level; @@ -24,13 +24,13 @@ @Validated public class ExerciseRecommendationController implements ExerciseRecommendationApi { - private final ExerciseQueryService exerciseQueryService; + private final ExerciseRecommendationQueryService exerciseRecommendationQueryService; @Override public ResponseEntity> getRecommendedExercises() { Long memberId = SecurityUtil.getCurrentMemberId(); - ExerciseRecommendationDTO.Response response = exerciseQueryService.getRecommendedExercises(memberId); + ExerciseRecommendationDTO.Response response = exerciseRecommendationQueryService.getRecommendedExercises(memberId); return BaseResponse.of(CommonSuccessCode.OK, response); } @@ -59,7 +59,7 @@ public ResponseEntity> .sortType(sortType) .build(); - ExerciseRecommendationCalendarDTO.Response response = exerciseQueryService + ExerciseRecommendationCalendarDTO.Response response = exerciseRecommendationQueryService .getRecommendedExerciseCalendar(memberId, startDate, endDate, isCockpleRecommend, filterSortType); return BaseResponse.of(CommonSuccessCode.OK, response); diff --git a/src/main/java/umc/cockple/demo/domain/exercise/controller/PartyExerciseController.java b/src/main/java/umc/cockple/demo/domain/exercise/controller/PartyExerciseController.java index 76fb90a95..8173ae4f3 100644 --- a/src/main/java/umc/cockple/demo/domain/exercise/controller/PartyExerciseController.java +++ b/src/main/java/umc/cockple/demo/domain/exercise/controller/PartyExerciseController.java @@ -6,7 +6,7 @@ import org.springframework.web.bind.annotation.RestController; import umc.cockple.demo.domain.exercise.controller.api.PartyExerciseApi; import umc.cockple.demo.domain.exercise.dto.PartyExerciseCalendarDTO; -import umc.cockple.demo.domain.exercise.service.ExerciseQueryService; +import umc.cockple.demo.domain.exercise.service.query.PartyExerciseQueryService; import umc.cockple.demo.global.response.BaseResponse; import umc.cockple.demo.global.response.code.status.CommonSuccessCode; import umc.cockple.demo.global.security.utils.SecurityUtil; @@ -18,14 +18,14 @@ @Validated public class PartyExerciseController implements PartyExerciseApi { - private final ExerciseQueryService exerciseQueryService; + private final PartyExerciseQueryService partyExerciseQueryService; @Override public ResponseEntity> getPartyExerciseCalender( Long partyId, LocalDate startDate, LocalDate endDate) { Long memberId = SecurityUtil.getCurrentMemberId(); - PartyExerciseCalendarDTO.Response response = exerciseQueryService.getPartyExerciseCalendar( + PartyExerciseCalendarDTO.Response response = partyExerciseQueryService.getPartyExerciseCalendar( partyId, memberId, startDate, endDate); return BaseResponse.of(CommonSuccessCode.OK, response); diff --git a/src/main/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryService.java b/src/main/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryService.java deleted file mode 100644 index b6bd377ed..000000000 --- a/src/main/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryService.java +++ /dev/null @@ -1,975 +0,0 @@ -package umc.cockple.demo.domain.exercise.service; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Slice; -import org.springframework.data.domain.Sort; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import umc.cockple.demo.domain.bookmark.repository.ExerciseBookmarkRepository; -import umc.cockple.demo.domain.exercise.converter.ExerciseConverter; -import umc.cockple.demo.domain.exercise.domain.Exercise; -import umc.cockple.demo.domain.exercise.domain.ExerciseAddr; -import umc.cockple.demo.domain.exercise.domain.Guest; -import umc.cockple.demo.domain.exercise.dto.*; -import umc.cockple.demo.domain.exercise.dto.ExerciseDetailDTO.ParticipantInfo; -import umc.cockple.demo.domain.exercise.enums.MyExerciseFilterType; -import umc.cockple.demo.domain.exercise.enums.MyExerciseOrderType; -import umc.cockple.demo.domain.exercise.enums.MyPartyExerciseOrderType; -import umc.cockple.demo.domain.exercise.exception.ExerciseErrorCode; -import umc.cockple.demo.domain.exercise.exception.ExerciseException; -import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; -import umc.cockple.demo.domain.exercise.repository.GuestRepository; -import umc.cockple.demo.domain.member.domain.Member; -import umc.cockple.demo.domain.member.domain.MemberAddr; -import umc.cockple.demo.domain.member.domain.MemberExercise; -import umc.cockple.demo.domain.member.domain.MemberParty; -import umc.cockple.demo.domain.member.repository.MemberExerciseRepository; -import umc.cockple.demo.domain.member.repository.MemberPartyRepository; -import umc.cockple.demo.domain.member.repository.MemberRepository; -import umc.cockple.demo.domain.party.domain.Party; -import umc.cockple.demo.domain.party.enums.PartyStatus; -import umc.cockple.demo.domain.party.exception.PartyErrorCode; -import umc.cockple.demo.domain.party.exception.PartyException; -import umc.cockple.demo.domain.party.repository.PartyRepository; -import umc.cockple.demo.global.enums.Gender; -import umc.cockple.demo.global.enums.Level; -import umc.cockple.demo.global.enums.Role; - -import java.time.LocalDate; -import java.util.*; -import java.util.stream.Collectors; - -@Service -@Transactional(readOnly = true) -@RequiredArgsConstructor -@Slf4j -public class ExerciseQueryService { - - private final ExerciseRepository exerciseRepository; - private final MemberRepository memberRepository; - private final MemberPartyRepository memberPartyRepository; - private final MemberExerciseRepository memberExerciseRepository; - private final GuestRepository guestRepository; - private final PartyRepository partyRepository; - private final ExerciseBookmarkRepository exerciseBookmarkRepository; - - private final ExerciseConverter exerciseConverter; - - public ExerciseDetailDTO.Response getExerciseDetail(Long exerciseId, Long memberId) { - - log.info("운동 조회 시작 - exerciseId = {}, memberId = {}", exerciseId, memberId); - - Exercise exercise = findExerciseWithBasicInfoOrThrow(exerciseId); - Member member = findMemberOrThrow(memberId); - - Party party = exercise.getParty(); - boolean isManager = checkManagerPermission(party, member); - - ExerciseDetailDTO.ExerciseInfo exerciseInfo = createExerciseInfo(exercise); - - List allParticipants = getAllSortedParticipants(exerciseId, party); - ParticipantGroups groups = splitParticipants(allParticipants, exercise.getMaxCapacity()); - - ExerciseDetailDTO.ParticipantGroup participantGroup = createParticipantGroup(groups.participants(), exercise.getMaxCapacity()); - ExerciseDetailDTO.WaitingGroup waitingGroup = createWaitingGroup(groups.waiting()); - - return exerciseConverter.toDetailResponse(isManager, exerciseInfo, participantGroup, waitingGroup); - } - - public ExerciseMyGuestListDTO.Response getMyInvitedGuests(Long exerciseId, Long memberId) { - - log.info("내가 초대한 게스트 조회 시작 - exerciseId = {}, memberId = {}", exerciseId, memberId); - - Exercise exercise = findExerciseWithBasicInfoOrThrow(exerciseId); - Member member = findMemberOrThrow(memberId); - - List myGuests = findGuestsByExerciseIdAndInviterId(exerciseId, memberId); - - if (myGuests.isEmpty()) { - log.info("초대한 게스트가 없어 빈 응답 반환 - exerciseId: {}, memberId: {}", exerciseId, memberId); - return exerciseConverter.toEmptyGuestListResponse(); - } - - List allParticipants = getAllSortedParticipants(exerciseId, exercise.getParty()); - Map guestNumberMap = createGuestNumberMap(allParticipants, exercise.getMaxCapacity()); - - String inviterName = member.getMemberName(); - List guestInfoList = buildGuestInfoList(myGuests, guestNumberMap, inviterName); - - ExerciseMyGuestListDTO.GuestStatistics statistics = calculateGuestStatistics(guestInfoList); - - log.info("내가 초대한 게스트 조회 완료 - exerciseId: {}", exerciseId); - - return exerciseConverter.toMyGuestListResponse(statistics, guestInfoList); - } - - public PartyExerciseCalendarDTO.Response getPartyExerciseCalendar(Long partyId, Long memberId, LocalDate startDate, LocalDate endDate) { - - log.info("모임 운동 캘린더 조회 시작 - partyId = {}, memberId = {}, startDate = {}, endDate = {}", - partyId, memberId, startDate, endDate); - - Party party = findPartyWithLevelsOrThrow(partyId); - Member member = findMemberOrThrow(memberId); - validateGetPartyExerciseCalender(startDate, endDate, party); - - Boolean isMember = isPartyMember(party, member); - DateRange dateRange = DateRange.calculateDateRange(startDate, endDate); - - List exercises = findExercisesByPartyIdAndDateRange(partyId, dateRange.start(), dateRange.end()); - - if (exercises.isEmpty()) { - log.info("해당 기간에 운동이 없어 빈 응답 반환 - partyId: {}, 기간: {} ~ {}", - partyId, dateRange.start(), dateRange.end()); - - return exerciseConverter.toEmptyPartyCalendarResponse( - dateRange.start(), dateRange.end(), isMember, party); - } - - Map participantCounts = getParticipantCountsMap( - partyId, dateRange.start(), dateRange.end()); - - List exerciseIds = getExerciseIds(exercises); - Map bookmarkStatus = getExerciseBookmarkStatus(memberId, exerciseIds); - Map participatingStatus = getExerciseParticipatingStatus(memberId, exerciseIds); - - log.info("모임 운동 캘린더 조회 완료 - partyId: {}, 조회된 운동 수: {}", partyId, exercises.size()); - - return exerciseConverter.toPartyCalendarResponse( - exercises, dateRange.start(), dateRange.end(), isMember, party, participantCounts, bookmarkStatus, participatingStatus); - } - - public MyExerciseCalendarDTO.Response getMyExerciseCalendar(Long memberId, LocalDate startDate, LocalDate endDate) { - - log.info("내 운동 캘린더 조회 시작 - memberId = {}, startDate = {}, endDate = {}", - memberId, startDate, endDate); - - Member member = findMemberOrThrow(memberId); - validateGetMyExerciseCalendar(startDate, endDate); - - DateRange dateRange = DateRange.calculateDateRange(startDate, endDate); - - List exercises = findExercisesByMemberIdAndDateRange(memberId, dateRange.start(), dateRange.end()); - - if (exercises.isEmpty()) { - log.info("해당 기간에 참여한 운동이 없어 빈 응답 반환 - memberId: {}, 기간: {} ~ {}", - memberId, dateRange.start(), dateRange.end()); - return exerciseConverter.toEmptyMyCalendarResponse(dateRange.start(), dateRange.end()); - } - - log.info("내 운동 캘린더 조회 완료 - memberId: {}, 조회된 운동 수: {}", memberId, exercises.size()); - - return exerciseConverter.toMyCalendarResponse(exercises, dateRange.start(), dateRange.end()); - } - - public MyPartyExerciseDTO.Response getMyPartyExercise(Long memberId) { - - log.info("내 모임 운동 조회 시작 - memberId = {}", memberId); - - Member member = findMemberOrThrow(memberId); - - List myPartyIds = findPartyIdsByMemberId(memberId); - - if (myPartyIds.isEmpty()) { - log.info("내가 속한 모임이 없음 - memberId = {}", memberId); - return exerciseConverter.toEmptyMyPartyExerciseResponse(); - } - - Pageable pageable = PageRequest.of(0, 6); - List recentExercises = findRecentExercisesByPartyIds(myPartyIds, pageable); - - log.info("내 모임 운동 조회 종료 - 조회된 운동 수 = {}", recentExercises.size()); - - return exerciseConverter.toMyPartyExerciseDTO(recentExercises); - } - - public MyPartyExerciseCalendarDTO.Response getMyPartyExerciseCalendar( - Long memberId, MyPartyExerciseOrderType orderType, LocalDate startDate, LocalDate endDate) { - - log.info("내 모임 운동 캘린더 조회 시작 - memberId = {}, orderType = {}, 기간 = {}~{}", memberId, orderType, startDate, endDate); - - Member member = findMemberOrThrow(memberId); - List myPartyIds = findPartyIdsByMemberId(memberId); - - DateRange dateRange = DateRange.calculateDateRange(startDate, endDate); - - if (myPartyIds.isEmpty()) { - log.info("내가 속한 모임이 없음 - memberId = {}", memberId); - return exerciseConverter.toEmptyMyPartyCalendarResponse(dateRange.start(), dateRange.end()); - } - - List exercises = findByPartyIdsAndDateRange(myPartyIds, dateRange.start(), dateRange.end()); - - if (exercises.isEmpty()) { - log.info("해당 기간에 내 모임의 운동이 없어 빈 응답 반환 - memberId: {}, 기간: {} ~ {}", - memberId, dateRange.start(), dateRange.end()); - return exerciseConverter.toEmptyMyPartyCalendarResponse(dateRange.start(), dateRange.end()); - } - - List exerciseIds = getExerciseIds(exercises); - Map bookmarkStatus = getExerciseBookmarkStatus(memberId, exerciseIds); - - Map participantCounts = getParticipantCountsMap(exerciseIds, dateRange.start(), dateRange.end()); - - log.info("내 운동 캘린더 조회 완료 - memberId: {}, 조회된 운동 수: {}", memberId, exercises.size()); - - return exerciseConverter.toMyPartyCalendarResponse( - exercises, dateRange.start(), dateRange.end(), bookmarkStatus, orderType, participantCounts); - } - - public ExerciseRecommendationDTO.Response getRecommendedExercises(Long memberId) { - - log.info("운동 추천 조회 시작 - memberId: {}", memberId); - - Member member = findMemberWithAddressesOrThrow(memberId); - MemberAddr mainAddr = findMainAddrOrThrow(member); - - List candidateExercises = findRecommendedExercises( - memberId, member.getGender(), member.getLevel(), member.getBirth().getYear()); - - List finalExercisesWithDistance = getFinalSortedExercises(candidateExercises, mainAddr); - List finalExercises = extractExercises(finalExercisesWithDistance); - - List exerciseIds = getExerciseIds(finalExercises); - Map bookmarkStatus = getExerciseBookmarkStatus(memberId, exerciseIds); - - log.info("운동 추천 조회 종료 - memberId: {}, 결과 : {}", memberId, exerciseIds.size()); - - return exerciseConverter.toExerciseRecommendationResponse(finalExercises, bookmarkStatus); - } - - public MyExerciseListDTO.Response getMyExercises( - Long memberId, MyExerciseFilterType filterType, MyExerciseOrderType orderType, Pageable pageable) { - - log.info("내 참여 운동 조회 시작 - memberId: {}, filterType: {}, orderType: {}", - memberId, filterType, orderType); - - Member member = findMemberOrThrow(memberId); - - Pageable sortedPageable = createSortedPageable(pageable, filterType, orderType); - - Slice exerciseSlice = findExercisesByFilterType(memberId, filterType, sortedPageable); - - if (exerciseSlice.isEmpty()) { - log.info("조회된 운동이 없음 - memberId: {}, filterType: {}", memberId, filterType); - return exerciseConverter.toEmptyMyExerciseList(); - } - - List exercises = exerciseSlice.getContent(); - List exerciseIds = exercises.stream().map(Exercise::getId).toList(); - - Map participantCountMap = getParticipantCountsMap(exerciseIds); - Map bookmarkStatus = getExerciseBookmarkStatus(memberId, exerciseIds); - Map isCompletedMap = getExerciseCompletionStatus(exercises); - - log.info("내 참여 운동 조회 완료 - memberId: {}, 조회된 운동 수: {}", memberId, exercises.size()); - - return exerciseConverter.toMyExerciseListResponse(exerciseSlice, participantCountMap, bookmarkStatus, isCompletedMap); - } - - public ExerciseBuildingDetailDTO.Response getBuildingExerciseDetails( - String buildingName, String streetAddr, LocalDate date, Long memberId) { - - log.info("건물 운동 상세 조회 시작 - 건물: {}, 주소: {}, 날짜: {}", buildingName, streetAddr, date); - - Member member = findMemberOrThrow(memberId); - List exercises = findExercisesByBuildingAndDate(buildingName, streetAddr, date); - - if (exercises.isEmpty()) { - log.info("건물에 운동이 존재하지 않습니다. - 건물: {}, 주소: {}, 날짜: {}", buildingName, streetAddr, date); - return exerciseConverter.toEmptyBuildingDetailResponse(buildingName, date); - } - - List exerciseIds = getExerciseIds(exercises); - Map bookmarkStatus = getExerciseBookmarkStatus(memberId, exerciseIds); - - log.info("건물 운동 상세 조회 종료 - 건물: {}, 주소: {}, 날짜: {}, 결과: {}", buildingName, streetAddr, date, exerciseIds.size()); - - return exerciseConverter.toBuildingDetailResponse(exercises, buildingName, bookmarkStatus, date); - } - - public ExerciseMapBuildingsDTO.Response getExerciseMapCalendarSummary( - ExerciseMapBuildingsDTO.Query query, Long memberId) { - - log.info("월간 운동 캘린더 요약 조회 시작 - 날짜: {}, 중심: ({}, {}), 반경: {}km", - query.date(), query.latitude(), query.longitude(), query.radiusKm()); - - Member member = findMemberWithAddressesOrThrow(memberId); - MemberAddr mainAddr = findMainAddrOrThrow(member); - ExerciseMapBuildingsDTO.Query searchQuery = - query.withFallbackLocation(mainAddr.getLatitude(), mainAddr.getLongitude()); - - DateRange dateRange = DateRange.calculateMonthlyStartAndEnd(query.date()); - - List exercises = findExercisesByMonthAndRadius(dateRange, searchQuery); - - Map> dailyBuildings = - groupExercisesByDateAndBuilding(exercises); - - log.info("월간 운동 캘린더 요약 조회 완료 - 조회된 운동 수: {}", exercises.size()); - - return exerciseConverter.toMapCalendarSummaryResponse( - dateRange.start().getYear(), dateRange.start().getMonthValue(), - searchQuery.latitude(), searchQuery.longitude(), searchQuery.radiusKm(), dailyBuildings); - } - - public ExerciseRecommendationCalendarDTO.Response getRecommendedExerciseCalendar( - Long memberId, - LocalDate startDate, - LocalDate endDate, - Boolean isCockpleRecommend, - ExerciseRecommendationCalendarDTO.FilterSortType filterSortType) { - - log.info("사용자 추천 운동 캘린더 조회 시작 - memberId: {}, 콕플추천: {}, 필터정렬: {}, 기간: {}~{}" - , memberId, isCockpleRecommend, filterSortType, startDate, endDate); - - Member member = findMemberWithAddressesOrThrow(memberId); - DateRange dateRange = DateRange.calculateDateRange(startDate, endDate); - - List exercises; - - if (isCockpleRecommend) { - exercises = findCockpleRecommendedExercisesByDateRange(member, dateRange); - } else { - exercises = findFilteredRecommendedExercises(member, dateRange, filterSortType); - } - - List exerciseIds = getExerciseIds(exercises); - Map bookmarkStatus = getExerciseBookmarkStatus(memberId, exerciseIds); - Map participantCountMap = getParticipantCountsMap(exerciseIds); - MemberAddr mainAddr = findMainAddrOrThrow(member); - - log.info("사용자 추천 운동 캘린더 조회 완료 - memberId: {}, 결과 수: {}", memberId, exercises.size()); - - return exerciseConverter.toRecommendationCalendarResponse( - exercises, bookmarkStatus, participantCountMap, mainAddr - , dateRange.start(), dateRange.end(), isCockpleRecommend, filterSortType); - } - - public ExerciseEditDetailDTO.Response getExerciseForEdit(Long exerciseId, Long memberId) { - log.info("운동 수정용 상세조회 시작 - exerciseId: {}, memberId: {}", exerciseId, memberId); - Exercise exercise = findExerciseWithBasicInfoOrThrow(exerciseId); - log.info("운동 수정용 상세조회 완료 - exerciseId: {}", exerciseId); - return exerciseConverter.toEditDetailResponse(exercise); - } - - // ========== 검증 메서드들 ========== - - private void validateGetPartyExerciseCalender(LocalDate startDate, LocalDate endDate, Party party) { - validatePartyIsActive(party); - validateDateRange(startDate, endDate); - } - - private void validateGetMyExerciseCalendar(LocalDate startDate, LocalDate endDate) { - validateDateRange(startDate, endDate); - } - - // ========== 세부 검증 메서드들 ========== - - private void validateDateRange(LocalDate startDate, LocalDate endDate) { - if (startDate == null && endDate == null) { - return; - } - - if (startDate == null || endDate == null) { - throw new ExerciseException(ExerciseErrorCode.INCOMPLETE_DATE_RANGE); - } - - if (!startDate.isBefore(endDate)) { - throw new ExerciseException(ExerciseErrorCode.INVALID_DATE_RANGE); - } - } - - private void validatePartyIsActive(Party party) { - if (party.getStatus() == PartyStatus.INACTIVE) { - throw new PartyException(PartyErrorCode.PARTY_IS_DELETED); - } - } - - // ========== 비즈니스 메서드 ========== - - private boolean checkManagerPermission(Party party, Member member) { - return memberPartyRepository.existsByPartyIdAndMemberIdAndRole( - party.getId(), member.getId(), Role.PARTY_MANAGER); - } - - private ExerciseDetailDTO.ExerciseInfo createExerciseInfo(Exercise exercise) { - ExerciseAddr addr = exercise.getExerciseAddr(); - - return ExerciseDetailDTO.ExerciseInfo.builder() - .notice(exercise.getNotice()) - .buildingName(addr.getBuildingName()) - .location(addr.getStreetAddr()) - .build(); - } - - private List getAllSortedParticipants(Long exerciseId, Party party) { - List memberExercises = findMemberExercisesWithMemberAndProfile(exerciseId); - List memberParticipants = buildMemberParticipantInfos(memberExercises, party); - - List guests = findGuests(exerciseId); - List guestParticipants = buildGuestParticipantInfos(guests); - - List allParticipants = new ArrayList<>(); - allParticipants.addAll(memberParticipants); - allParticipants.addAll(guestParticipants); - - allParticipants.sort(Comparator.comparing(ExerciseDetailDTO.ParticipantInfo::joinedAt)); - - return allParticipants; - } - - private ParticipantGroups splitParticipants( - List allParticipants, - int maxCapacity) { - - List participantList = createParticipantList(allParticipants, maxCapacity); - List waitingList = createWaitingList(allParticipants, maxCapacity); - - return new ParticipantGroups(participantList, waitingList); - } - - private ExerciseDetailDTO.ParticipantGroup createParticipantGroup( - List participants, - int maxCapacity) { - - return ExerciseDetailDTO.ParticipantGroup.builder() - .currentParticipantCount(participants.size()) - .totalCount(maxCapacity) - .manCount(countByGender(participants, "MALE")) - .womenCount(countByGender(participants, "FEMALE")) - .list(participants) - .build(); - } - - private ExerciseDetailDTO.WaitingGroup createWaitingGroup( - List waiting) { - - return ExerciseDetailDTO.WaitingGroup.builder() - .currentWaitingCount(waiting.size()) - .manCount(countByGender(waiting, "MALE")) - .womenCount(countByGender(waiting, "FEMALE")) - .list(waiting) - .build(); - } - - private Map createGuestNumberMap(List allParticipants, Integer maxCapacity) { - Map guestNumberMap = new HashMap<>(); - - for (int i = 0; i < allParticipants.size(); i++) { - ExerciseDetailDTO.ParticipantInfo participant = allParticipants.get(i); - - if ("GUEST".equals(participant.participantType())) { - if (i < maxCapacity) { - guestNumberMap.put(participant.participantId(), - ExerciseMyGuestListDTO.GuestGroups.participant(i + 1)); - } else { - int waitingNumber = i - maxCapacity + 1; - guestNumberMap.put(participant.participantId(), - ExerciseMyGuestListDTO.GuestGroups.waiting(waitingNumber)); - } - } - } - - return guestNumberMap; - } - - private List buildGuestInfoList( - List myGuests, - Map guestNumberMap, - String inviterName) { - - return myGuests.stream() - .map(guest -> exerciseConverter.toGuestInfo(guest, guestNumberMap, inviterName)) - .toList(); - } - - private ExerciseMyGuestListDTO.GuestStatistics calculateGuestStatistics(List guestInfoList) { - int totalCount = guestInfoList.size(); - int maleCount = (int) guestInfoList.stream() - .filter(guest -> guest.gender() == Gender.MALE) - .count(); - int femaleCount = totalCount - maleCount; - - return new ExerciseMyGuestListDTO.GuestStatistics(totalCount, maleCount, femaleCount); - } - - private boolean isPartyMember(Party party, Member member) { - return memberPartyRepository.existsByPartyAndMember(party, member); - } - - private List getFinalSortedExercises(List candidateExercises, MemberAddr mainAddr) { - return candidateExercises.stream() - .map(exercise -> { - double distance = calculateDistance( - mainAddr.getLatitude(), - mainAddr.getLongitude(), - exercise.getExerciseAddr().getLatitude(), - exercise.getExerciseAddr().getLongitude() - ); - return new ExerciseWithDistance(exercise, distance); - }) - .sorted(Comparator - .comparing(ExerciseWithDistance::distance) - .thenComparing(ewd -> ewd.exercise().getDate()) - .thenComparing(ewd -> ewd.exercise().getStartTime()) - ) - .limit(10) - .toList(); - } - - // 하버사인 공식을 이용한 거리 계산 - private double calculateDistance(double latitude, double longitude, double latitude1, double longitude1) { - final double R = 6371; // 지구 반지름 (km) - - double latDistance = Math.toRadians(latitude1 - latitude); - double lonDistance = Math.toRadians(longitude1 - longitude); - - double a = Math.sin(latDistance / 2) * Math.sin(latDistance / 2) - + Math.cos(Math.toRadians(latitude)) * Math.cos(Math.toRadians(latitude1)) - * Math.sin(lonDistance / 2) * Math.sin(lonDistance / 2); - - double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); - - return (float) (R * c); - } - - private static List extractExercises(List finalExercisesWithDistance) { - return finalExercisesWithDistance.stream() - .map(ExerciseWithDistance::exercise) - .toList(); - } - - private Pageable createSortedPageable( - Pageable pageable, MyExerciseFilterType filterType, MyExerciseOrderType orderType) { - Sort sort = createSortByFilterAndOrder(filterType, orderType); - return PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), sort); - } - - private Sort createSortByFilterAndOrder(MyExerciseFilterType filterType, MyExerciseOrderType orderType) { - return switch (filterType) { - case ALL -> createSortForAll(orderType); - case UPCOMING -> createSortForUpcoming(orderType); - case COMPLETED -> createSortForCompleted(orderType); - }; - } - - private Map getExerciseCompletionStatus(List exercises) { - return exercises.stream() - .collect(Collectors.toMap( - Exercise::getId, - Exercise::isAlreadyStarted - )); - } - - private Map> groupExercisesByDateAndBuilding(List exercises) { - Map> exercisesByDate = exercises.stream() - .collect(Collectors.groupingBy(Exercise::getDate)); - - return exercisesByDate.entrySet().stream() - .collect(Collectors.toMap( - Map.Entry::getKey, - entry -> createBuildingSummariesForDate(entry.getValue()), - (existing, replacement) -> existing, - TreeMap::new - )); - } - - private List createBuildingSummariesForDate(List dayExercises) { - Map> exercisesByBuilding = dayExercises.stream() - .collect(Collectors.groupingBy(this::createBuildingKey)); - - return exercisesByBuilding.keySet().stream() - .map(entry -> exerciseConverter.toBuildingSummary( - entry.name(), entry.address(), entry.latitude(), entry.longitude()) - ) - .toList(); - } - - // ========== 세부 비즈니스 메서드 ========== - - private List buildMemberParticipantInfos(List memberExercises, Party party) { - if (memberExercises.isEmpty()) { - return List.of(); - } - - List memberIds = memberExercises.stream() - .map(me -> me.getMember().getId()) - .toList(); - - Map partyMemberRoles = memberPartyRepository - .findMemberRolesByPartyAndMembers(party.getId(), memberIds) - .stream() - .collect(Collectors.toMap( - mp -> mp.getMember().getId(), - MemberParty::getRole - )); - - return memberExercises.stream() - .map(me -> { - if (partyMemberRoles.containsKey(me.getMember().getId())) { - return exerciseConverter.toParticipantInfoFromMember(me, partyMemberRoles); - } else { - return exerciseConverter.toParticipantInfoFromExternalMember(me); - } - }) - .toList(); - } - - private List buildGuestParticipantInfos(List guests) { - if (guests.isEmpty()) { - return List.of(); - } - - Set inviterIds = guests.stream() - .map(Guest::getInviterId) - .collect(Collectors.toSet()); - - Map inviterNames = memberRepository.findMemberNamesByIds(inviterIds); - - return guests.stream() - .map(guest -> { - String inviterName = inviterNames.getOrDefault(guest.getInviterId(), "알 수 없음"); - return exerciseConverter.toParticipantInfoFromGuest(guest, inviterName); - }) - .toList(); - } - - private List createParticipantList( - List allParticipants, - int maxCapacity) { - - List participantList = new ArrayList<>(); - int endIndex = Math.min(allParticipants.size(), maxCapacity); - - for (int i = 0; i < endIndex; i++) { - ExerciseDetailDTO.ParticipantInfo original = allParticipants.get(i); - ExerciseDetailDTO.ParticipantInfo participant = createParticipantWithNumber(original, i + 1); - participantList.add(participant); - } - - return participantList; - } - - private List createWaitingList( - List allParticipants, - int maxCapacity) { - - List waitingList = new ArrayList<>(); - - if (allParticipants.size() <= maxCapacity) { - return waitingList; - } - - for (int i = maxCapacity; i < allParticipants.size(); i++) { - ExerciseDetailDTO.ParticipantInfo original = allParticipants.get(i); - int waitingNumber = (i - maxCapacity) + 1; - ExerciseDetailDTO.ParticipantInfo waiting = createParticipantWithNumber(original, waitingNumber); - waitingList.add(waiting); - } - - return waitingList; - } - - private ExerciseDetailDTO.ParticipantInfo createParticipantWithNumber( - ExerciseDetailDTO.ParticipantInfo original, - int number) { - - return ExerciseDetailDTO.ParticipantInfo.builder() - .participantId(original.participantId()) - .participantNumber(number) - .profileImageUrl(original.profileImageUrl()) - .name(original.name()) - .gender(original.gender()) - .level(original.level()) - .participantType(original.participantType()) - .partyPosition(original.partyPosition()) - .inviterName(original.inviterName()) - .joinedAt(original.joinedAt()) - .isWithdrawn(original.isWithdrawn()) - .build(); - } - - private int countByGender(List participants, String gender) { - return (int) participants.stream() - .filter(p -> gender.equals(p.gender())) - .count(); - } - - private static List getExerciseIds(List exercises) { - return exercises.stream().map(Exercise::getId).toList(); - } - - private MemberAddr findMainAddrOrThrow(Member member) { - return member.getAddresses().stream() - .filter(MemberAddr::getIsMain) - .findFirst() - .orElseThrow(() -> new ExerciseException(ExerciseErrorCode.MAIN_ADDRESS_NULL)); - } - - private Sort createSortForAll(MyExerciseOrderType orderType) { - return switch (orderType) { - case LATEST -> Sort.by( - Sort.Order.desc("date"), - Sort.Order.desc("startTime") - ); - case OLDEST -> Sort.by( - Sort.Order.asc("date"), - Sort.Order.asc("startTime") - ); - }; - } - - private Sort createSortForUpcoming(MyExerciseOrderType orderType) { - return switch (orderType) { - case LATEST -> Sort.by( - Sort.Order.asc("date"), - Sort.Order.asc("startTime") - ); - case OLDEST -> Sort.by( - Sort.Order.desc("date"), - Sort.Order.desc("startTime") - ); - }; - } - - private Sort createSortForCompleted(MyExerciseOrderType orderType) { - return switch (orderType) { - case LATEST -> Sort.by( - Sort.Order.desc("date"), - Sort.Order.desc("startTime") - ); - case OLDEST -> Sort.by( - Sort.Order.asc("date"), - Sort.Order.asc("startTime") - ); - }; - } - - private BuildingKey createBuildingKey(Exercise exercise) { - var addr = exercise.getExerciseAddr(); - - return new BuildingKey( - addr.getBuildingName(), - addr.getStreetAddr(), - addr.getLatitude().doubleValue(), - addr.getLongitude().doubleValue() - ); - } - - // ========== 조회 메서드 ========== - - private Exercise findExerciseWithBasicInfoOrThrow(Long exerciseId) { - return exerciseRepository.findExerciseWithBasicInfo(exerciseId) - .orElseThrow(() -> new ExerciseException(ExerciseErrorCode.EXERCISE_NOT_FOUND)); - } - - private List findExercisesByPartyIdAndDateRange(Long partyId, LocalDate startDate, LocalDate endDate) { - return exerciseRepository.findByPartyIdAndDateRange(partyId, startDate, endDate); - } - - private List findExercisesByMemberIdAndDateRange(Long memberId, LocalDate startDate, LocalDate endDate) { - return exerciseRepository.findByMemberIdAndDateRange(memberId, startDate, endDate); - } - - private List findRecentExercisesByPartyIds(List myPartyIds, Pageable pageable) { - return exerciseRepository.findRecentExercisesByPartyIds(myPartyIds, pageable); - } - - private List findRecommendedExercises(Long memberId, Gender gender, Level level, int birthYear) { - return exerciseRepository.findExercisesByMemberIdAndLevelAndBirthYear(memberId, gender, level, birthYear); - } - - private List findByPartyIdsAndDateRange( - List myPartyIds, LocalDate startDate, LocalDate endDate) { - return exerciseRepository.findByPartyIdsAndDateRange(myPartyIds, startDate, endDate); - } - - private Slice findExercisesByFilterType(Long memberId, MyExerciseFilterType filterType, Pageable pageable) { - return switch (filterType) { - case ALL -> exerciseRepository.findMyExercisesWithPaging(memberId, pageable); - case UPCOMING -> exerciseRepository.findMyUpcomingExercisesWithPaging(memberId, pageable); - case COMPLETED -> exerciseRepository.findMyCompletedExercisesWithPaging(memberId, pageable); - }; - } - - private List findExercisesByBuildingAndDate(String buildingName, String streetAddr, LocalDate date) { - return exerciseRepository - .findExercisesByBuildingAndDate(buildingName, streetAddr, date); - } - - private List findExercisesByMonthAndRadius( - DateRange dateRange, ExerciseMapBuildingsDTO.Query searchQuery) { - return exerciseRepository.findExercisesByMonthAndRadius( - dateRange.start(), - dateRange.end(), - searchQuery.latitude(), - searchQuery.longitude(), - searchQuery.radiusKm() - ); - } - - private List findCockpleRecommendedExercisesByDateRange(Member member, DateRange dateRange) { - return exerciseRepository.findCockpleRecommendedExercisesByDateRange( - member.getId(), member.getGender(), member.getLevel(), member.getBirth().getYear(), - dateRange.start(), dateRange.end()); - } - - private List findFilteredRecommendedExercises( - Member member, DateRange dateRange, ExerciseRecommendationCalendarDTO.FilterSortType filterSortType) { - return exerciseRepository.findFilteredRecommendedExercisesForCalendar( - member.getId(), member.getBirth().getYear(), filterSortType, dateRange.start(), dateRange.end()); - } - - private Member findMemberOrThrow(Long memberId) { - return memberRepository.findById(memberId) - .orElseThrow(() -> new ExerciseException(ExerciseErrorCode.MEMBER_NOT_FOUND)); - } - - private Member findMemberWithAddressesOrThrow(Long memberId) { - return memberRepository.findMemberWithAddresses(memberId) - .orElseThrow(() -> new ExerciseException(ExerciseErrorCode.MEMBER_NOT_FOUND)); - } - - private List findMemberExercisesWithMemberAndProfile(Long exerciseId) { - return memberExerciseRepository.findByExerciseIdWithMemberAndProfile(exerciseId); - } - - private List findGuests(Long exerciseId) { - return guestRepository.findByExerciseId(exerciseId); - } - - private List findGuestsByExerciseIdAndInviterId(Long exerciseId, Long memberId) { - return guestRepository.findByExerciseIdAndInviterId(exerciseId, memberId); - } - - private Party findPartyWithLevelsOrThrow(Long partyId) { - return partyRepository.findByIdWithLevels(partyId) - .orElseThrow(() -> new ExerciseException(ExerciseErrorCode.PARTY_NOT_FOUND)); - } - - private List findPartyIdsByMemberId(Long memberId) { - return memberPartyRepository.findPartyIdsByMemberId(memberId); - } - - private Map getParticipantCountsMap(Long partyId, LocalDate start, LocalDate end) { - List countResults = exerciseRepository.findExerciseParticipantCounts( - partyId, start, end); - - return countResults.stream() - .collect(Collectors.toMap( - row -> ((Number) row[0]).longValue(), - row -> ((Number) row[1]).intValue() - )); - } - - private Map getParticipantCountsMap(List exerciseIds, LocalDate start, LocalDate end) { - List countResults = exerciseRepository.findExerciseParticipantCountsByExerciseIds( - exerciseIds, start, end); - - return countResults.stream() - .collect(Collectors.toMap( - row -> ((Number) row[0]).longValue(), - row -> ((Number) row[1]).intValue() - )); - } - - private Map getParticipantCountsMap(List exerciseIds) { - List countResults = exerciseRepository.findExerciseParticipantCountsByExerciseIds( - exerciseIds); - - return countResults.stream() - .collect(Collectors.toMap( - row -> ((Number) row[0]).longValue(), - row -> ((Number) row[1]).intValue() - )); - } - - private Map getExerciseBookmarkStatus(Long memberId, List exerciseIds) { - if (exerciseIds.isEmpty()) { - return Collections.emptyMap(); - } - - List bookmarkedExerciseIds = exerciseBookmarkRepository - .findAllExerciseIdsByMemberIdAndExerciseIds(memberId, exerciseIds); - - return exerciseIds.stream() - .collect(Collectors.toMap( - exerciseId -> exerciseId, - bookmarkedExerciseIds::contains - )); - } - - private Map getExerciseParticipatingStatus(Long memberId, List exerciseIds) { - if (exerciseIds.isEmpty()) { - return Collections.emptyMap(); - } - - List participatingExerciseIds = memberExerciseRepository - .findAllExerciseIdsByMemberAndExerciseIds(memberId, exerciseIds); - - return exerciseIds.stream() - .collect(Collectors.toMap( - exerciseId -> exerciseId, - participatingExerciseIds::contains - )); - } - - private record ParticipantGroups( - List participants, - List waiting - ) { - } - - private record DateRange(LocalDate start, LocalDate end) { - private static DateRange calculateMonthlyStartAndEnd(LocalDate date) { - LocalDate targetDate = (date != null) ? date : LocalDate.now(); - - LocalDate start = targetDate.withDayOfMonth(1); - int lastDay = targetDate.lengthOfMonth(); - LocalDate end = targetDate.withDayOfMonth(lastDay); - - return new DateRange(start, end); - } - - private static DateRange calculateDateRange(LocalDate startDate, LocalDate endDate) { - if (startDate != null && endDate != null) { - return new DateRange(startDate, endDate); - } - - LocalDate today = LocalDate.now(); - LocalDate thisWeekMonday = today.minusDays(today.getDayOfWeek().getValue() - 1); - LocalDate defaultStart = thisWeekMonday.minusWeeks(1); - LocalDate defaultEnd = thisWeekMonday.plusWeeks(3).plusDays(6); - - return new DateRange(defaultStart, defaultEnd); - } - } - - private record ExerciseWithDistance(Exercise exercise, double distance) { - } - - private record BuildingKey( - String name, - String address, - Double latitude, - Double longitude - ) { - @Override - public boolean equals(Object obj) { - if (this == obj) return true; - if (obj == null || getClass() != obj.getClass()) return false; - - BuildingKey that = (BuildingKey) obj; - return Objects.equals(name, that.name) && - Objects.equals(address, that.address); - } - - @Override - public int hashCode() { - return Objects.hash(name, address); - } - } -} diff --git a/src/main/java/umc/cockple/demo/domain/exercise/service/ExerciseValidator.java b/src/main/java/umc/cockple/demo/domain/exercise/service/ExerciseValidator.java index fabda2ec8..91d3d7d44 100644 --- a/src/main/java/umc/cockple/demo/domain/exercise/service/ExerciseValidator.java +++ b/src/main/java/umc/cockple/demo/domain/exercise/service/ExerciseValidator.java @@ -79,6 +79,10 @@ public void validateUpdateExercise(Exercise exercise, Member member, ExerciseUpd validateUpdateTime(request, exercise); } + public void validateExerciseManagementPermission(Exercise exercise, Long memberId) { + validateSubManagerPermission(memberId, exercise.getParty()); + } + // ========== 세부 검증 메서드들 ========== private void validatePartyIsActive(Party party) { diff --git a/src/main/java/umc/cockple/demo/domain/exercise/service/query/ExerciseGuestQueryService.java b/src/main/java/umc/cockple/demo/domain/exercise/service/query/ExerciseGuestQueryService.java new file mode 100644 index 000000000..403bbcc1b --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/exercise/service/query/ExerciseGuestQueryService.java @@ -0,0 +1,109 @@ +package umc.cockple.demo.domain.exercise.service.query; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import umc.cockple.demo.domain.exercise.converter.ExerciseConverter; +import umc.cockple.demo.domain.exercise.domain.Exercise; +import umc.cockple.demo.domain.exercise.domain.Guest; +import umc.cockple.demo.domain.exercise.dto.ExerciseDetailDTO; +import umc.cockple.demo.domain.exercise.dto.ExerciseMyGuestListDTO; +import umc.cockple.demo.domain.exercise.service.support.ExerciseParticipantInfoAssembler; +import umc.cockple.demo.domain.exercise.service.support.reader.ExerciseReader; +import umc.cockple.demo.domain.exercise.service.support.reader.GuestReader; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.service.support.MemberLookupService; +import umc.cockple.demo.global.enums.Gender; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +@Slf4j +public class ExerciseGuestQueryService { + + private final ExerciseReader exerciseReader; + private final GuestReader guestReader; + private final ExerciseParticipantInfoAssembler participantInfoAssembler; + private final MemberLookupService memberLookupService; + private final ExerciseConverter exerciseConverter; + + public ExerciseMyGuestListDTO.Response getMyInvitedGuests(Long exerciseId, Long memberId) { + + log.info("내가 초대한 게스트 조회 시작 - exerciseId = {}, memberId = {}", exerciseId, memberId); + + Exercise exercise = exerciseReader.findExerciseWithBasicInfoOrThrow(exerciseId); + Member member = memberLookupService.findByIdOrThrow(memberId); + + List myGuests = guestReader.findByExerciseIdAndInviterId(exerciseId, memberId); + + if (myGuests.isEmpty()) { + log.info("초대한 게스트가 없어 빈 응답 반환 - exerciseId: {}, memberId: {}", exerciseId, memberId); + return exerciseConverter.toEmptyGuestListResponse(); + } + + List allParticipants = participantInfoAssembler.getAllSortedParticipants( + exerciseId, exercise.getParty()); + Map guestNumberMap = createGuestNumberMap( + allParticipants, exercise.getMaxCapacity()); + + String inviterName = member.getMemberName(); + List guestInfoList = buildGuestInfoList(myGuests, guestNumberMap, inviterName); + + ExerciseMyGuestListDTO.GuestStatistics statistics = calculateGuestStatistics(guestInfoList); + + log.info("내가 초대한 게스트 조회 완료 - exerciseId: {}", exerciseId); + + return exerciseConverter.toMyGuestListResponse(statistics, guestInfoList); + } + + private Map createGuestNumberMap( + List allParticipants, + Integer maxCapacity) { + + Map guestNumberMap = new HashMap<>(); + + for (int i = 0; i < allParticipants.size(); i++) { + ExerciseDetailDTO.ParticipantInfo participant = allParticipants.get(i); + + if ("GUEST".equals(participant.participantType())) { + if (i < maxCapacity) { + guestNumberMap.put(participant.participantId(), + ExerciseMyGuestListDTO.GuestGroups.participant(i + 1)); + } else { + int waitingNumber = i - maxCapacity + 1; + guestNumberMap.put(participant.participantId(), + ExerciseMyGuestListDTO.GuestGroups.waiting(waitingNumber)); + } + } + } + + return guestNumberMap; + } + + private List buildGuestInfoList( + List myGuests, + Map guestNumberMap, + String inviterName) { + + return myGuests.stream() + .map(guest -> exerciseConverter.toGuestInfo(guest, guestNumberMap, inviterName)) + .toList(); + } + + private ExerciseMyGuestListDTO.GuestStatistics calculateGuestStatistics( + List guestInfoList) { + + int totalCount = guestInfoList.size(); + int maleCount = (int) guestInfoList.stream() + .filter(guest -> guest.gender() == Gender.MALE) + .count(); + int femaleCount = totalCount - maleCount; + + return new ExerciseMyGuestListDTO.GuestStatistics(totalCount, maleCount, femaleCount); + } +} diff --git a/src/main/java/umc/cockple/demo/domain/exercise/service/query/ExerciseLifecycleQueryService.java b/src/main/java/umc/cockple/demo/domain/exercise/service/query/ExerciseLifecycleQueryService.java new file mode 100644 index 000000000..962805339 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/exercise/service/query/ExerciseLifecycleQueryService.java @@ -0,0 +1,180 @@ +package umc.cockple.demo.domain.exercise.service.query; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import umc.cockple.demo.domain.exercise.converter.ExerciseConverter; +import umc.cockple.demo.domain.exercise.domain.Exercise; +import umc.cockple.demo.domain.exercise.domain.ExerciseAddr; +import umc.cockple.demo.domain.exercise.dto.*; +import umc.cockple.demo.domain.exercise.service.ExerciseValidator; +import umc.cockple.demo.domain.exercise.service.support.ExerciseParticipantInfoAssembler; +import umc.cockple.demo.domain.exercise.service.support.reader.ExerciseParticipantReader; +import umc.cockple.demo.domain.exercise.service.support.reader.ExerciseReader; +import umc.cockple.demo.domain.member.service.support.MemberLookupService; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.party.domain.Party; + +import java.util.*; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +@Slf4j +public class ExerciseLifecycleQueryService { + + private final ExerciseReader exerciseReader; + private final ExerciseParticipantReader exerciseParticipantReader; + private final ExerciseParticipantInfoAssembler participantInfoAssembler; + private final MemberLookupService memberLookupService; + private final ExerciseValidator exerciseValidator; + + private final ExerciseConverter exerciseConverter; + + public ExerciseDetailDTO.Response getExerciseDetail(Long exerciseId, Long memberId) { + + log.info("운동 조회 시작 - exerciseId = {}, memberId = {}", exerciseId, memberId); + + Exercise exercise = exerciseReader.findExerciseWithBasicInfoOrThrow(exerciseId); + Member member = memberLookupService.findByIdOrThrow(memberId); + + Party party = exercise.getParty(); + boolean isManager = checkManagerPermission(party, member); + + ExerciseDetailDTO.ExerciseInfo exerciseInfo = createExerciseInfo(exercise); + + List allParticipants = participantInfoAssembler.getAllSortedParticipants(exerciseId, party); + ParticipantGroups groups = splitParticipants(allParticipants, exercise.getMaxCapacity()); + + ExerciseDetailDTO.ParticipantGroup participantGroup = createParticipantGroup(groups.participants(), exercise.getMaxCapacity()); + ExerciseDetailDTO.WaitingGroup waitingGroup = createWaitingGroup(groups.waiting()); + + return exerciseConverter.toDetailResponse(isManager, exerciseInfo, participantGroup, waitingGroup); + } + + public ExerciseEditDetailDTO.Response getExerciseForEdit(Long exerciseId, Long memberId) { + log.info("운동 수정용 상세조회 시작 - exerciseId: {}, memberId: {}", exerciseId, memberId); + Exercise exercise = exerciseReader.findExerciseWithBasicInfoOrThrow(exerciseId); + exerciseValidator.validateExerciseManagementPermission(exercise, memberId); + log.info("운동 수정용 상세조회 완료 - exerciseId: {}", exerciseId); + return exerciseConverter.toEditDetailResponse(exercise); + } + + // ========== 비즈니스 메서드 ========== + + private boolean checkManagerPermission(Party party, Member member) { + return exerciseParticipantReader.hasManagerPermission(party, member); + } + + private ExerciseDetailDTO.ExerciseInfo createExerciseInfo(Exercise exercise) { + ExerciseAddr addr = exercise.getExerciseAddr(); + + return ExerciseDetailDTO.ExerciseInfo.builder() + .notice(exercise.getNotice()) + .buildingName(addr.getBuildingName()) + .location(addr.getStreetAddr()) + .build(); + } + + private ParticipantGroups splitParticipants( + List allParticipants, + int maxCapacity) { + + List participantList = createParticipantList(allParticipants, maxCapacity); + List waitingList = createWaitingList(allParticipants, maxCapacity); + + return new ParticipantGroups(participantList, waitingList); + } + + private ExerciseDetailDTO.ParticipantGroup createParticipantGroup( + List participants, + int maxCapacity) { + + return ExerciseDetailDTO.ParticipantGroup.builder() + .currentParticipantCount(participants.size()) + .totalCount(maxCapacity) + .manCount(countByGender(participants, "MALE")) + .womenCount(countByGender(participants, "FEMALE")) + .list(participants) + .build(); + } + + private ExerciseDetailDTO.WaitingGroup createWaitingGroup( + List waiting) { + + return ExerciseDetailDTO.WaitingGroup.builder() + .currentWaitingCount(waiting.size()) + .manCount(countByGender(waiting, "MALE")) + .womenCount(countByGender(waiting, "FEMALE")) + .list(waiting) + .build(); + } + + private List createParticipantList( + List allParticipants, + int maxCapacity) { + + List participantList = new ArrayList<>(); + int endIndex = Math.min(allParticipants.size(), maxCapacity); + + for (int i = 0; i < endIndex; i++) { + ExerciseDetailDTO.ParticipantInfo original = allParticipants.get(i); + ExerciseDetailDTO.ParticipantInfo participant = createParticipantWithNumber(original, i + 1); + participantList.add(participant); + } + + return participantList; + } + + private List createWaitingList( + List allParticipants, + int maxCapacity) { + + List waitingList = new ArrayList<>(); + + if (allParticipants.size() <= maxCapacity) { + return waitingList; + } + + for (int i = maxCapacity; i < allParticipants.size(); i++) { + ExerciseDetailDTO.ParticipantInfo original = allParticipants.get(i); + int waitingNumber = (i - maxCapacity) + 1; + ExerciseDetailDTO.ParticipantInfo waiting = createParticipantWithNumber(original, waitingNumber); + waitingList.add(waiting); + } + + return waitingList; + } + + private ExerciseDetailDTO.ParticipantInfo createParticipantWithNumber( + ExerciseDetailDTO.ParticipantInfo original, + int number) { + + return ExerciseDetailDTO.ParticipantInfo.builder() + .participantId(original.participantId()) + .participantNumber(number) + .profileImageUrl(original.profileImageUrl()) + .name(original.name()) + .gender(original.gender()) + .level(original.level()) + .participantType(original.participantType()) + .partyPosition(original.partyPosition()) + .inviterName(original.inviterName()) + .joinedAt(original.joinedAt()) + .isWithdrawn(original.isWithdrawn()) + .build(); + } + + private int countByGender(List participants, String gender) { + return (int) participants.stream() + .filter(p -> gender.equals(p.gender())) + .count(); + } + + private record ParticipantGroups( + List participants, + List waiting + ) { + } +} diff --git a/src/main/java/umc/cockple/demo/domain/exercise/service/query/ExerciseMapQueryService.java b/src/main/java/umc/cockple/demo/domain/exercise/service/query/ExerciseMapQueryService.java new file mode 100644 index 000000000..d67e9547f --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/exercise/service/query/ExerciseMapQueryService.java @@ -0,0 +1,152 @@ +package umc.cockple.demo.domain.exercise.service.query; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import umc.cockple.demo.domain.exercise.converter.ExerciseConverter; +import umc.cockple.demo.domain.exercise.domain.Exercise; +import umc.cockple.demo.domain.exercise.dto.ExerciseBuildingDetailDTO; +import umc.cockple.demo.domain.exercise.dto.ExerciseMapBuildingsDTO; +import umc.cockple.demo.domain.bookmark.service.query.lookup.ExerciseBookmarkLookupService; +import umc.cockple.demo.domain.exercise.service.support.reader.ExerciseReader; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.domain.MemberAddr; +import umc.cockple.demo.domain.member.service.support.MemberLookupService; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; +import java.util.stream.Collectors; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +@Slf4j +public class ExerciseMapQueryService { + + private final ExerciseReader exerciseReader; + private final ExerciseBookmarkLookupService exerciseBookmarkLookupService; + private final MemberLookupService memberLookupService; + private final ExerciseConverter exerciseConverter; + + public ExerciseBuildingDetailDTO.Response getBuildingExerciseDetails( + String buildingName, String streetAddr, LocalDate date, Long memberId) { + + log.info("건물 운동 상세 조회 시작 - 건물: {}, 주소: {}, 날짜: {}", buildingName, streetAddr, date); + + List exercises = exerciseReader.findByBuildingAndDate(buildingName, streetAddr, date); + + if (exercises.isEmpty()) { + log.info("건물에 운동이 존재하지 않습니다. - 건물: {}, 주소: {}, 날짜: {}", buildingName, streetAddr, date); + return exerciseConverter.toEmptyBuildingDetailResponse(buildingName, date); + } + + List exerciseIds = getExerciseIds(exercises); + Map bookmarkStatus = exerciseBookmarkLookupService.getBookmarkStatus(memberId, exerciseIds); + + log.info("건물 운동 상세 조회 종료 - 건물: {}, 주소: {}, 날짜: {}, 결과: {}", buildingName, streetAddr, date, exerciseIds.size()); + + return exerciseConverter.toBuildingDetailResponse(exercises, buildingName, bookmarkStatus, date); + } + + public ExerciseMapBuildingsDTO.Response getExerciseMapCalendarSummary( + ExerciseMapBuildingsDTO.Query query, Long memberId) { + + log.info("월간 운동 캘린더 요약 조회 시작 - 날짜: {}, 중심: ({}, {}), 반경: {}km", + query.date(), query.latitude(), query.longitude(), query.radiusKm()); + + Member member = memberLookupService.findWithAddressesOrThrow(memberId); + MemberAddr mainAddr = memberLookupService.findMainAddressOrThrow(member); + ExerciseMapBuildingsDTO.Query searchQuery = + query.withFallbackLocation(mainAddr.getLatitude(), mainAddr.getLongitude()); + + DateRange dateRange = DateRange.calculateMonthlyStartAndEnd(query.date()); + + List exercises = exerciseReader.findByMonthAndRadius(dateRange.start(), dateRange.end(), searchQuery); + + Map> dailyBuildings = + groupExercisesByDateAndBuilding(exercises); + + log.info("월간 운동 캘린더 요약 조회 완료 - 조회된 운동 수: {}", exercises.size()); + + return exerciseConverter.toMapCalendarSummaryResponse( + dateRange.start().getYear(), dateRange.start().getMonthValue(), + searchQuery.latitude(), searchQuery.longitude(), searchQuery.radiusKm(), dailyBuildings); + } + + private static List getExerciseIds(List exercises) { + return exercises.stream().map(Exercise::getId).toList(); + } + + private Map> groupExercisesByDateAndBuilding(List exercises) { + Map> exercisesByDate = exercises.stream() + .collect(Collectors.groupingBy(Exercise::getDate)); + + return exercisesByDate.entrySet().stream() + .collect(Collectors.toMap( + Map.Entry::getKey, + entry -> createBuildingSummariesForDate(entry.getValue()), + (existing, replacement) -> existing, + TreeMap::new + )); + } + + private List createBuildingSummariesForDate(List dayExercises) { + Map> exercisesByBuilding = dayExercises.stream() + .collect(Collectors.groupingBy(this::createBuildingKey)); + + return exercisesByBuilding.keySet().stream() + .map(entry -> exerciseConverter.toBuildingSummary( + entry.name(), entry.address(), entry.latitude(), entry.longitude()) + ) + .toList(); + } + + private BuildingKey createBuildingKey(Exercise exercise) { + var addr = exercise.getExerciseAddr(); + + return new BuildingKey( + addr.getBuildingName(), + addr.getStreetAddr(), + addr.getLatitude().doubleValue(), + addr.getLongitude().doubleValue() + ); + } + + private record DateRange(LocalDate start, LocalDate end) { + private static DateRange calculateMonthlyStartAndEnd(LocalDate date) { + LocalDate targetDate = (date != null) ? date : LocalDate.now(); + + LocalDate start = targetDate.withDayOfMonth(1); + int lastDay = targetDate.lengthOfMonth(); + LocalDate end = targetDate.withDayOfMonth(lastDay); + + return new DateRange(start, end); + } + } + + private record BuildingKey( + String name, + String address, + Double latitude, + Double longitude + ) { + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null || getClass() != obj.getClass()) return false; + + BuildingKey that = (BuildingKey) obj; + return Objects.equals(name, that.name) && + Objects.equals(address, that.address); + } + + @Override + public int hashCode() { + return Objects.hash(name, address); + } + } +} diff --git a/src/main/java/umc/cockple/demo/domain/exercise/service/query/ExerciseMyQueryService.java b/src/main/java/umc/cockple/demo/domain/exercise/service/query/ExerciseMyQueryService.java new file mode 100644 index 000000000..14c76fb29 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/exercise/service/query/ExerciseMyQueryService.java @@ -0,0 +1,241 @@ +package umc.cockple.demo.domain.exercise.service.query; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import umc.cockple.demo.domain.exercise.converter.ExerciseConverter; +import umc.cockple.demo.domain.exercise.domain.Exercise; +import umc.cockple.demo.domain.exercise.dto.MyExerciseCalendarDTO; +import umc.cockple.demo.domain.exercise.dto.MyExerciseListDTO; +import umc.cockple.demo.domain.exercise.dto.MyPartyExerciseCalendarDTO; +import umc.cockple.demo.domain.exercise.dto.MyPartyExerciseDTO; +import umc.cockple.demo.domain.exercise.enums.MyExerciseFilterType; +import umc.cockple.demo.domain.exercise.enums.MyExerciseOrderType; +import umc.cockple.demo.domain.exercise.enums.MyPartyExerciseOrderType; +import umc.cockple.demo.domain.exercise.exception.ExerciseErrorCode; +import umc.cockple.demo.domain.exercise.exception.ExerciseException; +import umc.cockple.demo.domain.exercise.service.query.lookup.ExerciseParticipantCountLookupService; +import umc.cockple.demo.domain.bookmark.service.query.lookup.ExerciseBookmarkLookupService; +import umc.cockple.demo.domain.exercise.service.support.reader.ExerciseParticipantReader; +import umc.cockple.demo.domain.exercise.service.support.reader.ExerciseReader; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +@Slf4j +public class ExerciseMyQueryService { + + private final ExerciseReader exerciseReader; + private final ExerciseParticipantReader exerciseParticipantReader; + private final ExerciseParticipantCountLookupService exerciseParticipantCountLookupService; + private final ExerciseBookmarkLookupService exerciseBookmarkLookupService; + private final ExerciseConverter exerciseConverter; + + public MyExerciseCalendarDTO.Response getMyExerciseCalendar(Long memberId, LocalDate startDate, LocalDate endDate) { + + log.info("내 운동 캘린더 조회 시작 - memberId = {}, startDate = {}, endDate = {}", + memberId, startDate, endDate); + + validateDateRange(startDate, endDate); + + DateRange dateRange = DateRange.calculateDateRange(startDate, endDate); + + List exercises = exerciseReader.findByMemberIdAndDateRange(memberId, dateRange.start(), dateRange.end()); + + if (exercises.isEmpty()) { + log.info("해당 기간에 참여한 운동이 없어 빈 응답 반환 - memberId: {}, 기간: {} ~ {}", + memberId, dateRange.start(), dateRange.end()); + return exerciseConverter.toEmptyMyCalendarResponse(dateRange.start(), dateRange.end()); + } + + log.info("내 운동 캘린더 조회 완료 - memberId: {}, 조회된 운동 수: {}", memberId, exercises.size()); + + return exerciseConverter.toMyCalendarResponse(exercises, dateRange.start(), dateRange.end()); + } + + public MyPartyExerciseDTO.Response getMyPartyExercise(Long memberId) { + + log.info("내 모임 운동 조회 시작 - memberId = {}", memberId); + + + List myPartyIds = exerciseParticipantReader.findPartyIdsByMemberId(memberId); + + if (myPartyIds.isEmpty()) { + log.info("내가 속한 모임이 없음 - memberId = {}", memberId); + return exerciseConverter.toEmptyMyPartyExerciseResponse(); + } + + Pageable pageable = PageRequest.of(0, 6); + List recentExercises = exerciseReader.findRecentByPartyIds(myPartyIds, pageable); + + log.info("내 모임 운동 조회 종료 - 조회된 운동 수 = {}", recentExercises.size()); + + return exerciseConverter.toMyPartyExerciseDTO(recentExercises); + } + + public MyPartyExerciseCalendarDTO.Response getMyPartyExerciseCalendar( + Long memberId, MyPartyExerciseOrderType orderType, LocalDate startDate, LocalDate endDate) { + + log.info("내 모임 운동 캘린더 조회 시작 - memberId = {}, orderType = {}, 기간 = {}~{}", memberId, orderType, startDate, endDate); + + List myPartyIds = exerciseParticipantReader.findPartyIdsByMemberId(memberId); + + DateRange dateRange = DateRange.calculateDateRange(startDate, endDate); + + if (myPartyIds.isEmpty()) { + log.info("내가 속한 모임이 없음 - memberId = {}", memberId); + return exerciseConverter.toEmptyMyPartyCalendarResponse(dateRange.start(), dateRange.end()); + } + + List exercises = exerciseReader.findByPartyIdsAndDateRange(myPartyIds, dateRange.start(), dateRange.end()); + + if (exercises.isEmpty()) { + log.info("해당 기간에 내 모임의 운동이 없어 빈 응답 반환 - memberId: {}, 기간: {} ~ {}", + memberId, dateRange.start(), dateRange.end()); + return exerciseConverter.toEmptyMyPartyCalendarResponse(dateRange.start(), dateRange.end()); + } + + List exerciseIds = getExerciseIds(exercises); + Map bookmarkStatus = exerciseBookmarkLookupService.getBookmarkStatus(memberId, exerciseIds); + + Map participantCounts = exerciseParticipantCountLookupService.getParticipantCountsByExerciseIdsAndDateRange( + exerciseIds, dateRange.start(), dateRange.end()); + + log.info("내 운동 캘린더 조회 완료 - memberId: {}, 조회된 운동 수: {}", memberId, exercises.size()); + + return exerciseConverter.toMyPartyCalendarResponse( + exercises, dateRange.start(), dateRange.end(), bookmarkStatus, orderType, participantCounts); + } + + public MyExerciseListDTO.Response getMyExercises( + Long memberId, MyExerciseFilterType filterType, MyExerciseOrderType orderType, Pageable pageable) { + + log.info("내 참여 운동 조회 시작 - memberId: {}, filterType: {}, orderType: {}", + memberId, filterType, orderType); + + + Pageable sortedPageable = createSortedPageable(pageable, filterType, orderType); + + Slice exerciseSlice = exerciseReader.findByFilterType(memberId, filterType, sortedPageable); + + if (exerciseSlice.isEmpty()) { + log.info("조회된 운동이 없음 - memberId: {}, filterType: {}", memberId, filterType); + return exerciseConverter.toEmptyMyExerciseList(); + } + + List exercises = exerciseSlice.getContent(); + List exerciseIds = exercises.stream().map(Exercise::getId).toList(); + + Map participantCountMap = exerciseParticipantCountLookupService.getParticipantCountsByExerciseIds(exerciseIds); + Map bookmarkStatus = exerciseBookmarkLookupService.getBookmarkStatus(memberId, exerciseIds); + Map isCompletedMap = getExerciseCompletionStatus(exercises); + + log.info("내 참여 운동 조회 완료 - memberId: {}, 조회된 운동 수: {}", memberId, exercises.size()); + + return exerciseConverter.toMyExerciseListResponse(exerciseSlice, participantCountMap, bookmarkStatus, isCompletedMap); + } + + private void validateDateRange(LocalDate startDate, LocalDate endDate) { + if (startDate == null && endDate == null) { + return; + } + + if (startDate == null || endDate == null) { + throw new ExerciseException(ExerciseErrorCode.INCOMPLETE_DATE_RANGE); + } + + if (!startDate.isBefore(endDate)) { + throw new ExerciseException(ExerciseErrorCode.INVALID_DATE_RANGE); + } + } + + private Pageable createSortedPageable( + Pageable pageable, MyExerciseFilterType filterType, MyExerciseOrderType orderType) { + Sort sort = createSortByFilterAndOrder(filterType, orderType); + return PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), sort); + } + + private Sort createSortByFilterAndOrder(MyExerciseFilterType filterType, MyExerciseOrderType orderType) { + return switch (filterType) { + case ALL -> createSortForAll(orderType); + case UPCOMING -> createSortForUpcoming(orderType); + case COMPLETED -> createSortForCompleted(orderType); + }; + } + + private Map getExerciseCompletionStatus(List exercises) { + return exercises.stream() + .collect(Collectors.toMap( + Exercise::getId, + Exercise::isAlreadyStarted + )); + } + + private static List getExerciseIds(List exercises) { + return exercises.stream().map(Exercise::getId).toList(); + } + + private Sort createSortForAll(MyExerciseOrderType orderType) { + return switch (orderType) { + case LATEST -> Sort.by( + Sort.Order.desc("date"), + Sort.Order.desc("startTime") + ); + case OLDEST -> Sort.by( + Sort.Order.asc("date"), + Sort.Order.asc("startTime") + ); + }; + } + + private Sort createSortForUpcoming(MyExerciseOrderType orderType) { + return switch (orderType) { + case LATEST -> Sort.by( + Sort.Order.asc("date"), + Sort.Order.asc("startTime") + ); + case OLDEST -> Sort.by( + Sort.Order.desc("date"), + Sort.Order.desc("startTime") + ); + }; + } + + private Sort createSortForCompleted(MyExerciseOrderType orderType) { + return switch (orderType) { + case LATEST -> Sort.by( + Sort.Order.desc("date"), + Sort.Order.desc("startTime") + ); + case OLDEST -> Sort.by( + Sort.Order.asc("date"), + Sort.Order.asc("startTime") + ); + }; + } + + private record DateRange(LocalDate start, LocalDate end) { + private static DateRange calculateDateRange(LocalDate startDate, LocalDate endDate) { + if (startDate != null && endDate != null) { + return new DateRange(startDate, endDate); + } + + LocalDate today = LocalDate.now(); + LocalDate thisWeekMonday = today.minusDays(today.getDayOfWeek().getValue() - 1); + LocalDate defaultStart = thisWeekMonday.minusWeeks(1); + LocalDate defaultEnd = thisWeekMonday.plusWeeks(3).plusDays(6); + + return new DateRange(defaultStart, defaultEnd); + } + } +} diff --git a/src/main/java/umc/cockple/demo/domain/exercise/service/query/ExerciseRecommendationQueryService.java b/src/main/java/umc/cockple/demo/domain/exercise/service/query/ExerciseRecommendationQueryService.java new file mode 100644 index 000000000..e2c853d66 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/exercise/service/query/ExerciseRecommendationQueryService.java @@ -0,0 +1,138 @@ +package umc.cockple.demo.domain.exercise.service.query; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import umc.cockple.demo.domain.exercise.converter.ExerciseConverter; +import umc.cockple.demo.domain.exercise.domain.Exercise; +import umc.cockple.demo.domain.exercise.dto.ExerciseRecommendationCalendarDTO; +import umc.cockple.demo.domain.exercise.dto.ExerciseRecommendationDTO; +import umc.cockple.demo.domain.exercise.service.query.lookup.ExerciseParticipantCountLookupService; +import umc.cockple.demo.domain.bookmark.service.query.lookup.ExerciseBookmarkLookupService; +import umc.cockple.demo.domain.exercise.service.support.ExerciseDistanceCalculator; +import umc.cockple.demo.domain.exercise.service.support.reader.ExerciseReader; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.domain.MemberAddr; +import umc.cockple.demo.domain.member.service.support.MemberLookupService; + +import java.time.LocalDate; +import java.util.Comparator; +import java.util.List; +import java.util.Map; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +@Slf4j +public class ExerciseRecommendationQueryService { + + private final ExerciseReader exerciseReader; + private final ExerciseBookmarkLookupService exerciseBookmarkLookupService; + private final ExerciseParticipantCountLookupService exerciseParticipantCountLookupService; + private final ExerciseDistanceCalculator exerciseDistanceCalculator; + private final MemberLookupService memberLookupService; + private final ExerciseConverter exerciseConverter; + + public ExerciseRecommendationDTO.Response getRecommendedExercises(Long memberId) { + + log.info("운동 추천 조회 시작 - memberId: {}", memberId); + + Member member = memberLookupService.findWithAddressesOrThrow(memberId); + MemberAddr mainAddr = memberLookupService.findMainAddressOrThrow(member); + + List candidateExercises = exerciseReader.findRecommendedExercises( + memberId, member.getGender(), member.getLevel(), member.getBirth().getYear()); + + List finalExercisesWithDistance = getFinalSortedExercises(candidateExercises, mainAddr); + List finalExercises = extractExercises(finalExercisesWithDistance); + + List exerciseIds = getExerciseIds(finalExercises); + Map bookmarkStatus = exerciseBookmarkLookupService.getBookmarkStatus(memberId, exerciseIds); + + log.info("운동 추천 조회 종료 - memberId: {}, 결과 : {}", memberId, exerciseIds.size()); + + return exerciseConverter.toExerciseRecommendationResponse(finalExercises, bookmarkStatus); + } + + public ExerciseRecommendationCalendarDTO.Response getRecommendedExerciseCalendar( + Long memberId, + LocalDate startDate, + LocalDate endDate, + Boolean isCockpleRecommend, + ExerciseRecommendationCalendarDTO.FilterSortType filterSortType) { + + log.info("사용자 추천 운동 캘린더 조회 시작 - memberId: {}, 콕플추천: {}, 필터정렬: {}, 기간: {}~{}" + , memberId, isCockpleRecommend, filterSortType, startDate, endDate); + + Member member = memberLookupService.findWithAddressesOrThrow(memberId); + DateRange dateRange = DateRange.calculateDateRange(startDate, endDate); + + List exercises; + + if (isCockpleRecommend) { + exercises = exerciseReader.findCockpleRecommendedByDateRange(member, dateRange.start(), dateRange.end()); + } else { + exercises = exerciseReader.findFilteredRecommended(member, dateRange.start(), dateRange.end(), filterSortType); + } + + List exerciseIds = getExerciseIds(exercises); + Map bookmarkStatus = exerciseBookmarkLookupService.getBookmarkStatus(memberId, exerciseIds); + Map participantCountMap = exerciseParticipantCountLookupService.getParticipantCountsByExerciseIds(exerciseIds); + MemberAddr mainAddr = memberLookupService.findMainAddressOrThrow(member); + + log.info("사용자 추천 운동 캘린더 조회 완료 - memberId: {}, 결과 수: {}", memberId, exercises.size()); + + return exerciseConverter.toRecommendationCalendarResponse( + exercises, bookmarkStatus, participantCountMap, mainAddr + , dateRange.start(), dateRange.end(), isCockpleRecommend, filterSortType); + } + + private List getFinalSortedExercises(List candidateExercises, MemberAddr mainAddr) { + return candidateExercises.stream() + .map(exercise -> { + double distance = exerciseDistanceCalculator.calculate( + mainAddr.getLatitude(), + mainAddr.getLongitude(), + exercise.getExerciseAddr().getLatitude(), + exercise.getExerciseAddr().getLongitude() + ); + return new ExerciseWithDistance(exercise, distance); + }) + .sorted(Comparator + .comparing(ExerciseWithDistance::distance) + .thenComparing(ewd -> ewd.exercise().getDate()) + .thenComparing(ewd -> ewd.exercise().getStartTime()) + ) + .limit(10) + .toList(); + } + + private static List extractExercises(List finalExercisesWithDistance) { + return finalExercisesWithDistance.stream() + .map(ExerciseWithDistance::exercise) + .toList(); + } + + private static List getExerciseIds(List exercises) { + return exercises.stream().map(Exercise::getId).toList(); + } + + private record DateRange(LocalDate start, LocalDate end) { + private static DateRange calculateDateRange(LocalDate startDate, LocalDate endDate) { + if (startDate != null && endDate != null) { + return new DateRange(startDate, endDate); + } + + LocalDate today = LocalDate.now(); + LocalDate thisWeekMonday = today.minusDays(today.getDayOfWeek().getValue() - 1); + LocalDate defaultStart = thisWeekMonday.minusWeeks(1); + LocalDate defaultEnd = thisWeekMonday.plusWeeks(3).plusDays(6); + + return new DateRange(defaultStart, defaultEnd); + } + } + + private record ExerciseWithDistance(Exercise exercise, double distance) { + } +} diff --git a/src/main/java/umc/cockple/demo/domain/exercise/service/query/PartyExerciseQueryService.java b/src/main/java/umc/cockple/demo/domain/exercise/service/query/PartyExerciseQueryService.java new file mode 100644 index 000000000..f6134fbdc --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/exercise/service/query/PartyExerciseQueryService.java @@ -0,0 +1,121 @@ +package umc.cockple.demo.domain.exercise.service.query; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import umc.cockple.demo.domain.exercise.converter.ExerciseConverter; +import umc.cockple.demo.domain.exercise.domain.Exercise; +import umc.cockple.demo.domain.exercise.dto.PartyExerciseCalendarDTO; +import umc.cockple.demo.domain.exercise.exception.ExerciseErrorCode; +import umc.cockple.demo.domain.exercise.exception.ExerciseException; +import umc.cockple.demo.domain.exercise.service.query.lookup.ExerciseParticipantCountLookupService; +import umc.cockple.demo.domain.bookmark.service.query.lookup.ExerciseBookmarkLookupService; +import umc.cockple.demo.domain.exercise.service.support.reader.ExerciseParticipantReader; +import umc.cockple.demo.domain.exercise.service.support.reader.ExerciseReader; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.service.support.MemberLookupService; +import umc.cockple.demo.domain.party.domain.Party; +import umc.cockple.demo.domain.party.enums.PartyStatus; +import umc.cockple.demo.domain.party.exception.PartyErrorCode; +import umc.cockple.demo.domain.party.exception.PartyException; +import umc.cockple.demo.domain.party.service.support.PartyLookupService; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +@Slf4j +public class PartyExerciseQueryService { + + private final ExerciseReader exerciseReader; + private final ExerciseParticipantReader exerciseParticipantReader; + private final ExerciseParticipantCountLookupService exerciseParticipantCountLookupService; + private final ExerciseBookmarkLookupService exerciseBookmarkLookupService; + private final MemberLookupService memberLookupService; + private final PartyLookupService partyLookupService; + private final ExerciseConverter exerciseConverter; + + public PartyExerciseCalendarDTO.Response getPartyExerciseCalendar( + Long partyId, Long memberId, LocalDate startDate, LocalDate endDate) { + + log.info("모임 운동 캘린더 조회 시작 - partyId = {}, memberId = {}, startDate = {}, endDate = {}", + partyId, memberId, startDate, endDate); + + Party party = partyLookupService.findByIdWithLevelsOrThrow(partyId); + Member member = memberLookupService.findByIdOrThrow(memberId); + validateGetPartyExerciseCalendar(startDate, endDate, party); + + Boolean isMember = exerciseParticipantReader.isPartyMember(party, member); + DateRange dateRange = DateRange.calculateDateRange(startDate, endDate); + + List exercises = exerciseReader.findByPartyIdAndDateRange(partyId, dateRange.start(), dateRange.end()); + + if (exercises.isEmpty()) { + log.info("해당 기간에 운동이 없어 빈 응답 반환 - partyId: {}, 기간: {} ~ {}", + partyId, dateRange.start(), dateRange.end()); + + return exerciseConverter.toEmptyPartyCalendarResponse( + dateRange.start(), dateRange.end(), isMember, party); + } + + Map participantCounts = exerciseParticipantCountLookupService.getParticipantCountsByPartyIdAndDateRange( + partyId, dateRange.start(), dateRange.end()); + + List exerciseIds = getExerciseIds(exercises); + Map bookmarkStatus = exerciseBookmarkLookupService.getBookmarkStatus(memberId, exerciseIds); + Map participatingStatus = exerciseParticipantReader.getParticipatingStatus(memberId, exerciseIds); + + log.info("모임 운동 캘린더 조회 완료 - partyId: {}, 조회된 운동 수: {}", partyId, exercises.size()); + + return exerciseConverter.toPartyCalendarResponse( + exercises, dateRange.start(), dateRange.end(), isMember, party, participantCounts, bookmarkStatus, participatingStatus); + } + + private void validateGetPartyExerciseCalendar(LocalDate startDate, LocalDate endDate, Party party) { + validatePartyIsActive(party); + validateDateRange(startDate, endDate); + } + + private void validateDateRange(LocalDate startDate, LocalDate endDate) { + if (startDate == null && endDate == null) { + return; + } + + if (startDate == null || endDate == null) { + throw new ExerciseException(ExerciseErrorCode.INCOMPLETE_DATE_RANGE); + } + + if (!startDate.isBefore(endDate)) { + throw new ExerciseException(ExerciseErrorCode.INVALID_DATE_RANGE); + } + } + + private void validatePartyIsActive(Party party) { + if (party.getStatus() == PartyStatus.INACTIVE) { + throw new PartyException(PartyErrorCode.PARTY_IS_DELETED); + } + } + + private static List getExerciseIds(List exercises) { + return exercises.stream().map(Exercise::getId).toList(); + } + + private record DateRange(LocalDate start, LocalDate end) { + private static DateRange calculateDateRange(LocalDate startDate, LocalDate endDate) { + if (startDate != null && endDate != null) { + return new DateRange(startDate, endDate); + } + + LocalDate today = LocalDate.now(); + LocalDate thisWeekMonday = today.minusDays(today.getDayOfWeek().getValue() - 1); + LocalDate defaultStart = thisWeekMonday.minusWeeks(1); + LocalDate defaultEnd = thisWeekMonday.plusWeeks(3).plusDays(6); + + return new DateRange(defaultStart, defaultEnd); + } + } +} diff --git a/src/main/java/umc/cockple/demo/domain/exercise/service/query/lookup/ExerciseParticipantCountLookupService.java b/src/main/java/umc/cockple/demo/domain/exercise/service/query/lookup/ExerciseParticipantCountLookupService.java new file mode 100644 index 000000000..0f4bd0461 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/exercise/service/query/lookup/ExerciseParticipantCountLookupService.java @@ -0,0 +1,58 @@ +package umc.cockple.demo.domain.exercise.service.query.lookup; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; + +import java.time.LocalDate; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ExerciseParticipantCountLookupService { + + private final ExerciseRepository exerciseRepository; + + public Map getParticipantCountsByPartyIdAndDateRange( + Long partyId, + LocalDate start, + LocalDate end) { + List countResults = exerciseRepository.findExerciseParticipantCounts(partyId, start, end); + return toCountMap(countResults); + } + + public Map getParticipantCountsByExerciseIdsAndDateRange( + List exerciseIds, + LocalDate start, + LocalDate end) { + if (exerciseIds.isEmpty()) { + return Collections.emptyMap(); + } + + List countResults = exerciseRepository.findExerciseParticipantCountsByExerciseIds( + exerciseIds, start, end); + return toCountMap(countResults); + } + + public Map getParticipantCountsByExerciseIds(List exerciseIds) { + if (exerciseIds.isEmpty()) { + return Collections.emptyMap(); + } + + List countResults = exerciseRepository.findExerciseParticipantCountsByExerciseIds(exerciseIds); + return toCountMap(countResults); + } + + private Map toCountMap(List countResults) { + return countResults.stream() + .collect(Collectors.toMap( + row -> ((Number) row[0]).longValue(), + row -> ((Number) row[1]).intValue() + )); + } +} diff --git a/src/main/java/umc/cockple/demo/domain/exercise/service/support/ExerciseDistanceCalculator.java b/src/main/java/umc/cockple/demo/domain/exercise/service/support/ExerciseDistanceCalculator.java new file mode 100644 index 000000000..a706b16d1 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/exercise/service/support/ExerciseDistanceCalculator.java @@ -0,0 +1,22 @@ +package umc.cockple.demo.domain.exercise.service.support; + +import org.springframework.stereotype.Component; + +@Component +public class ExerciseDistanceCalculator { + + private static final double EARTH_RADIUS_KM = 6371; + + public double calculate(double latitude, double longitude, double latitude1, double longitude1) { + double latDistance = Math.toRadians(latitude1 - latitude); + double lonDistance = Math.toRadians(longitude1 - longitude); + + double a = Math.sin(latDistance / 2) * Math.sin(latDistance / 2) + + Math.cos(Math.toRadians(latitude)) * Math.cos(Math.toRadians(latitude1)) + * Math.sin(lonDistance / 2) * Math.sin(lonDistance / 2); + + double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + + return (float) (EARTH_RADIUS_KM * c); + } +} diff --git a/src/main/java/umc/cockple/demo/domain/exercise/service/support/ExerciseParticipantInfoAssembler.java b/src/main/java/umc/cockple/demo/domain/exercise/service/support/ExerciseParticipantInfoAssembler.java new file mode 100644 index 000000000..4c76cfbca --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/exercise/service/support/ExerciseParticipantInfoAssembler.java @@ -0,0 +1,90 @@ +package umc.cockple.demo.domain.exercise.service.support; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import umc.cockple.demo.domain.exercise.converter.ExerciseConverter; +import umc.cockple.demo.domain.exercise.domain.Guest; +import umc.cockple.demo.domain.exercise.dto.ExerciseDetailDTO; +import umc.cockple.demo.domain.exercise.service.support.reader.ExerciseParticipantReader; +import umc.cockple.demo.domain.exercise.service.support.reader.GuestReader; +import umc.cockple.demo.domain.member.domain.MemberExercise; +import umc.cockple.demo.domain.member.service.support.MemberLookupService; +import umc.cockple.demo.domain.party.domain.Party; +import umc.cockple.demo.global.enums.Role; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ExerciseParticipantInfoAssembler { + + private final ExerciseParticipantReader exerciseParticipantReader; + private final GuestReader guestReader; + private final MemberLookupService memberLookupService; + private final ExerciseConverter exerciseConverter; + + public List getAllSortedParticipants(Long exerciseId, Party party) { + List memberExercises = exerciseParticipantReader.findMemberExercisesWithMemberAndProfile(exerciseId); + List memberParticipants = buildMemberParticipantInfos(memberExercises, party); + + List guests = guestReader.findByExerciseId(exerciseId); + List guestParticipants = buildGuestParticipantInfos(guests); + + List allParticipants = new ArrayList<>(); + allParticipants.addAll(memberParticipants); + allParticipants.addAll(guestParticipants); + + allParticipants.sort(Comparator.comparing(ExerciseDetailDTO.ParticipantInfo::joinedAt)); + + return allParticipants; + } + + private List buildMemberParticipantInfos(List memberExercises, Party party) { + if (memberExercises.isEmpty()) { + return List.of(); + } + + List memberIds = memberExercises.stream() + .map(me -> me.getMember().getId()) + .toList(); + + Map partyMemberRoles = exerciseParticipantReader + .findMemberRolesByPartyAndMembers(party.getId(), memberIds); + + return memberExercises.stream() + .map(me -> { + if (partyMemberRoles.containsKey(me.getMember().getId())) { + return exerciseConverter.toParticipantInfoFromMember(me, partyMemberRoles); + } else { + return exerciseConverter.toParticipantInfoFromExternalMember(me); + } + }) + .toList(); + } + + private List buildGuestParticipantInfos(List guests) { + if (guests.isEmpty()) { + return List.of(); + } + + Set inviterIds = guests.stream() + .map(Guest::getInviterId) + .collect(Collectors.toSet()); + + Map inviterNames = memberLookupService.findNamesByIds(inviterIds); + + return guests.stream() + .map(guest -> { + String inviterName = inviterNames.getOrDefault(guest.getInviterId(), "알 수 없음"); + return exerciseConverter.toParticipantInfoFromGuest(guest, inviterName); + }) + .toList(); + } +} diff --git a/src/main/java/umc/cockple/demo/domain/exercise/service/support/reader/ExerciseParticipantReader.java b/src/main/java/umc/cockple/demo/domain/exercise/service/support/reader/ExerciseParticipantReader.java new file mode 100644 index 000000000..8fda6b37d --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/exercise/service/support/reader/ExerciseParticipantReader.java @@ -0,0 +1,70 @@ +package umc.cockple.demo.domain.exercise.service.support.reader; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.domain.MemberExercise; +import umc.cockple.demo.domain.member.domain.MemberParty; +import umc.cockple.demo.domain.member.repository.MemberExerciseRepository; +import umc.cockple.demo.domain.member.repository.MemberPartyRepository; +import umc.cockple.demo.domain.party.domain.Party; +import umc.cockple.demo.global.enums.Role; + +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ExerciseParticipantReader { + + private final MemberExerciseRepository memberExerciseRepository; + private final MemberPartyRepository memberPartyRepository; + + public boolean hasManagerPermission(Party party, Member member) { + return memberPartyRepository.existsByPartyIdAndMemberIdAndRole( + party.getId(), member.getId(), Role.PARTY_MANAGER); + } + + public boolean isPartyMember(Party party, Member member) { + return memberPartyRepository.existsByPartyAndMember(party, member); + } + + public List findPartyIdsByMemberId(Long memberId) { + return memberPartyRepository.findPartyIdsByMemberId(memberId); + } + + public List findMemberExercisesWithMemberAndProfile(Long exerciseId) { + return memberExerciseRepository.findByExerciseIdWithMemberAndProfile(exerciseId); + } + + public Map findMemberRolesByPartyAndMembers(Long partyId, List memberIds) { + return memberPartyRepository.findMemberRolesByPartyAndMembers(partyId, memberIds) + .stream() + .collect(Collectors.toMap( + memberParty -> memberParty.getMember().getId(), + MemberParty::getRole + )); + } + + public Map getParticipatingStatus(Long memberId, List exerciseIds) { + if (exerciseIds.isEmpty()) { + return Collections.emptyMap(); + } + + List participatingExerciseIds = memberExerciseRepository + .findAllExerciseIdsByMemberAndExerciseIds(memberId, exerciseIds); + Set participatingExerciseIdSet = new HashSet<>(participatingExerciseIds); + + return exerciseIds.stream() + .collect(Collectors.toMap( + exerciseId -> exerciseId, + participatingExerciseIdSet::contains + )); + } +} diff --git a/src/main/java/umc/cockple/demo/domain/exercise/service/support/reader/ExerciseReader.java b/src/main/java/umc/cockple/demo/domain/exercise/service/support/reader/ExerciseReader.java new file mode 100644 index 000000000..e32d6ab88 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/exercise/service/support/reader/ExerciseReader.java @@ -0,0 +1,93 @@ +package umc.cockple.demo.domain.exercise.service.support.reader; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import umc.cockple.demo.domain.exercise.domain.Exercise; +import umc.cockple.demo.domain.exercise.dto.ExerciseMapBuildingsDTO; +import umc.cockple.demo.domain.exercise.dto.ExerciseRecommendationCalendarDTO; +import umc.cockple.demo.domain.exercise.enums.MyExerciseFilterType; +import umc.cockple.demo.domain.exercise.exception.ExerciseErrorCode; +import umc.cockple.demo.domain.exercise.exception.ExerciseException; +import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; + +import java.time.LocalDate; +import java.util.List; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ExerciseReader { + + private final ExerciseRepository exerciseRepository; + + public Exercise findExerciseWithBasicInfoOrThrow(Long exerciseId) { + return exerciseRepository.findExerciseWithBasicInfo(exerciseId) + .orElseThrow(() -> new ExerciseException(ExerciseErrorCode.EXERCISE_NOT_FOUND)); + } + + public List findByPartyIdAndDateRange(Long partyId, LocalDate startDate, LocalDate endDate) { + return exerciseRepository.findByPartyIdAndDateRange(partyId, startDate, endDate); + } + + public List findByMemberIdAndDateRange(Long memberId, LocalDate startDate, LocalDate endDate) { + return exerciseRepository.findByMemberIdAndDateRange(memberId, startDate, endDate); + } + + public List findRecentByPartyIds(List partyIds, Pageable pageable) { + return exerciseRepository.findRecentExercisesByPartyIds(partyIds, pageable); + } + + public List findRecommendedExercises(Long memberId, Gender gender, Level level, int birthYear) { + return exerciseRepository.findExercisesByMemberIdAndLevelAndBirthYear(memberId, gender, level, birthYear); + } + + public List findByPartyIdsAndDateRange(List partyIds, LocalDate startDate, LocalDate endDate) { + return exerciseRepository.findByPartyIdsAndDateRange(partyIds, startDate, endDate); + } + + public Slice findByFilterType(Long memberId, MyExerciseFilterType filterType, Pageable pageable) { + return switch (filterType) { + case ALL -> exerciseRepository.findMyExercisesWithPaging(memberId, pageable); + case UPCOMING -> exerciseRepository.findMyUpcomingExercisesWithPaging(memberId, pageable); + case COMPLETED -> exerciseRepository.findMyCompletedExercisesWithPaging(memberId, pageable); + }; + } + + public List findByBuildingAndDate(String buildingName, String streetAddr, LocalDate date) { + return exerciseRepository.findExercisesByBuildingAndDate(buildingName, streetAddr, date); + } + + public List findByMonthAndRadius( + LocalDate startDate, + LocalDate endDate, + ExerciseMapBuildingsDTO.Query searchQuery) { + return exerciseRepository.findExercisesByMonthAndRadius( + startDate, + endDate, + searchQuery.latitude(), + searchQuery.longitude(), + searchQuery.radiusKm() + ); + } + + public List findCockpleRecommendedByDateRange(Member member, LocalDate startDate, LocalDate endDate) { + return exerciseRepository.findCockpleRecommendedExercisesByDateRange( + member.getId(), member.getGender(), member.getLevel(), member.getBirth().getYear(), + startDate, endDate); + } + + public List findFilteredRecommended( + Member member, + LocalDate startDate, + LocalDate endDate, + ExerciseRecommendationCalendarDTO.FilterSortType filterSortType) { + return exerciseRepository.findFilteredRecommendedExercisesForCalendar( + member.getId(), member.getBirth().getYear(), filterSortType, startDate, endDate); + } +} diff --git a/src/main/java/umc/cockple/demo/domain/exercise/service/support/reader/GuestReader.java b/src/main/java/umc/cockple/demo/domain/exercise/service/support/reader/GuestReader.java new file mode 100644 index 000000000..5be196eac --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/exercise/service/support/reader/GuestReader.java @@ -0,0 +1,25 @@ +package umc.cockple.demo.domain.exercise.service.support.reader; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import umc.cockple.demo.domain.exercise.domain.Guest; +import umc.cockple.demo.domain.exercise.repository.GuestRepository; + +import java.util.List; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class GuestReader { + + private final GuestRepository guestRepository; + + public List findByExerciseId(Long exerciseId) { + return guestRepository.findByExerciseId(exerciseId); + } + + public List findByExerciseIdAndInviterId(Long exerciseId, Long inviterId) { + return guestRepository.findByExerciseIdAndInviterId(exerciseId, inviterId); + } +} diff --git a/src/main/java/umc/cockple/demo/domain/file/enums/ObjectStorageDeleteSourceType.java b/src/main/java/umc/cockple/demo/domain/file/enums/ObjectStorageDeleteSourceType.java index b37a75dd9..4f6777e45 100644 --- a/src/main/java/umc/cockple/demo/domain/file/enums/ObjectStorageDeleteSourceType.java +++ b/src/main/java/umc/cockple/demo/domain/file/enums/ObjectStorageDeleteSourceType.java @@ -1,5 +1,6 @@ package umc.cockple.demo.domain.file.enums; public enum ObjectStorageDeleteSourceType { - PARTY_CHAT_ROOM + PARTY_CHAT_ROOM, + MEMBER_PROFILE_IMG } diff --git a/src/main/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxService.java b/src/main/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxService.java index 7273517c3..4ce5b2eb6 100644 --- a/src/main/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxService.java +++ b/src/main/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxService.java @@ -42,4 +42,20 @@ public void enqueuePartyChatFiles(Long chatRoomId, Collection objectKeys objectStorageDeleteOutboxRepository.saveAll(outboxes); log.info("Object storage 삭제 outbox 등록 - chatRoomId: {}, 파일 수: {}", chatRoomId, outboxes.size()); } + + @Transactional + public void enqueueProfileImage(Long memberId, String objectKey) { + if (!StringUtils.hasText(objectKey)) { + return; + } + + objectStorageDeleteOutboxRepository.save( + ObjectStorageDeleteOutbox.pending( + objectKey, + ObjectStorageDeleteSourceType.MEMBER_PROFILE_IMG, + memberId + ) + ); + log.info("Object storage 삭제 outbox 등록 - memberId: {}, objectKey: {}", memberId, objectKey); + } } diff --git a/src/main/java/umc/cockple/demo/domain/member/controller/MemberController.java b/src/main/java/umc/cockple/demo/domain/member/controller/MemberController.java index 27a13950d..ea373651c 100644 --- a/src/main/java/umc/cockple/demo/domain/member/controller/MemberController.java +++ b/src/main/java/umc/cockple/demo/domain/member/controller/MemberController.java @@ -14,7 +14,9 @@ import umc.cockple.demo.domain.member.exception.MemberErrorCode; import umc.cockple.demo.domain.member.exception.MemberException; import umc.cockple.demo.domain.member.service.MemberCommandService; +import umc.cockple.demo.domain.member.service.MemberProfileUpdateExecutor; import umc.cockple.demo.domain.member.service.MemberQueryService; +import umc.cockple.demo.global.config.WebProperties; import umc.cockple.demo.global.jwt.domain.TokenRefreshResponse; import umc.cockple.demo.global.oauth2.service.KakaoOauthService; import umc.cockple.demo.global.response.BaseResponse; @@ -37,8 +39,11 @@ public class MemberController { private final MemberCommandService memberCommandService; + private final MemberProfileUpdateExecutor memberProfileUpdateExecutor; private final MemberQueryService memberQueryService; private final KakaoOauthService kakaoOauthService; + private final WebProperties webProperties; + @PostMapping("/oauth/login") @Operation(summary = "카카오 소셜 로그인 API", @@ -47,15 +52,7 @@ public ResponseEntity login(@RequestBody @Valid KakaoLogi KakaoLoginResponseDTO response = kakaoOauthService.signup(requestDTO.code()); - ResponseCookie cookie = ResponseCookie.from("refreshToken", response.refreshToken()) - .httpOnly(true) - .secure(true) - .path("/") - .maxAge(Duration.ofDays(7)) - .sameSite("None") - .domain(".cockple.store") - .build() - ; + ResponseCookie cookie = buildRefreshTokenCookie(response.refreshToken()); return ResponseEntity.ok() .header(HttpHeaders.SET_COOKIE, cookie.toString()) @@ -96,7 +93,7 @@ public BaseResponse registerMemberDetailInfo(@RequestBody @Valid MemberD Long memberId = SecurityUtil.getCurrentMemberId(); - memberCommandService.memberDetailInfo(memberId, requestDTO); + memberProfileUpdateExecutor.registerMemberDetailInfo(memberId, requestDTO); return BaseResponse.success(CommonSuccessCode.OK); } @@ -114,14 +111,7 @@ public ResponseEntity refresh(@CookieValue("refreshToken") // 리프레시 토큰 유효성 검사 TokenRefreshResponse response = kakaoOauthService.validateMember(refreshToken); - ResponseCookie cookie = ResponseCookie.from("refreshToken", response.refreshToken()) - .httpOnly(true) - .secure(true) - .path("/") - .maxAge(Duration.ofDays(7)) - .sameSite("None") - .domain(".cockple.store") - .build(); + ResponseCookie cookie = buildRefreshTokenCookie(response.refreshToken()); return ResponseEntity.ok() .header(HttpHeaders.SET_COOKIE, cookie.toString()) @@ -181,7 +171,7 @@ public BaseResponse updateProfile(@RequestBody @Valid UpdateProfileReque Long memberId = SecurityUtil.getCurrentMemberId(); - memberCommandService.updateProfile(requestDTO, memberId); + memberProfileUpdateExecutor.updateProfile(requestDTO, memberId); return BaseResponse.success(CommonSuccessCode.OK); } @@ -242,4 +232,22 @@ public BaseResponse> getAllAddress() { List addresses = memberQueryService.getAllAddress(memberId); return BaseResponse.success(CommonSuccessCode.OK, addresses); } + + /** + * refreshToken 쿠키 생성(로그인/재발급 공통) + */ + private ResponseCookie buildRefreshTokenCookie(String refreshToken) { + ResponseCookie.ResponseCookieBuilder builder = ResponseCookie.from("refreshToken", refreshToken) + .httpOnly(true) + .secure(true) + .path("/") + .maxAge(Duration.ofDays(7)) + .sameSite("None"); + + String cookieDomain = webProperties.getCookieDomain(); + if (cookieDomain != null && !cookieDomain.isBlank()) { + builder.domain(cookieDomain); + } + return builder.build(); + } } diff --git a/src/main/java/umc/cockple/demo/domain/member/converter/MemberConverter.java b/src/main/java/umc/cockple/demo/domain/member/converter/MemberConverter.java index 05bb6389f..d2da579df 100644 --- a/src/main/java/umc/cockple/demo/domain/member/converter/MemberConverter.java +++ b/src/main/java/umc/cockple/demo/domain/member/converter/MemberConverter.java @@ -12,7 +12,7 @@ public class MemberConverter { - public static GetProfileResponseDTO memberToGetProfileResponseDTO(Member member, int goldMedalCnt, + public static GetProfileResponseDTO memberToGetProfileResponseDTO(Member member, int partyCnt, int goldMedalCnt, int silverMedalCnt, int bronzeMedalCnt, String imgUrl) { return GetProfileResponseDTO.builder() .memberName(member.getMemberName()) @@ -20,7 +20,7 @@ public static GetProfileResponseDTO memberToGetProfileResponseDTO(Member member, .gender(member.getGender()) .level(member.getLevel()) .profileImgUrl(imgUrl) - .myPartyCnt(member.getMemberParties().size()) + .myPartyCnt(partyCnt) .myGoldMedalCnt(goldMedalCnt) .mySilverMedalCnt(silverMedalCnt) .myBronzeMedalCnt(bronzeMedalCnt) diff --git a/src/main/java/umc/cockple/demo/domain/member/domain/Member.java b/src/main/java/umc/cockple/demo/domain/member/domain/Member.java index dce1140dc..764554ca1 100644 --- a/src/main/java/umc/cockple/demo/domain/member/domain/Member.java +++ b/src/main/java/umc/cockple/demo/domain/member/domain/Member.java @@ -21,6 +21,7 @@ import java.time.Period; import java.util.ArrayList; import java.util.List; +import java.util.Optional; import static umc.cockple.demo.domain.member.dto.CreateMemberAddrDTO.*; @@ -59,6 +60,10 @@ public class Member extends BaseEntity { private String fcmToken; + @ColumnDefault("0") + @Column(nullable = false) + private long tokenVersion; + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL) @Builder.Default private List contests = new ArrayList<>(); @@ -120,13 +125,23 @@ public void updateProfileImg(ProfileImg newProfileImg) { } } - public void updateMemberFirst(MemberDetailInfoRequestDTO requestDto, List keywords, ProfileImg img) { - this.memberName = requestDto.memberName(); - this.gender = requestDto.gender(); - this.birth = requestDto.birth(); - this.level = requestDto.level(); - this.keywords = keywords; - updateProfileImg(img); + + public Optional changeProfileImage(String imgKey) { + if (this.profileImg != null) { + if (this.profileImg.getImgKey().equals(imgKey)) { + return Optional.empty(); // 동일 이미지 -> 변경 없음 + } + String oldKey = this.profileImg.getImgKey(); + this.profileImg.updateProfile(imgKey); // UPDATE (version 증가 -> 낙관적 락 적용) + return Optional.of(oldKey); + } + + // 기존 이미지 없음 -> 새로 생성 + ProfileImg newProfileImg = ProfileImg.builder() + .imgKey(imgKey) + .build(); + updateProfileImg(newProfileImg); + return Optional.empty(); } public void updateMemberFirst(MemberDetailInfoRequestDTO requestDto, List keywords) { @@ -138,14 +153,6 @@ public void updateMemberFirst(MemberDetailInfoRequestDTO requestDto, List keywords, ProfileImg img) { - this.memberName = requestDto.memberName(); - this.birth = requestDto.birth(); - this.level = requestDto.level(); - this.keywords = keywords; - updateProfileImg(img); - } - public void updateMember(UpdateProfileRequestDTO requestDto, List keywords) { this.memberName = requestDto.memberName(); this.birth = requestDto.birth(); diff --git a/src/main/java/umc/cockple/demo/domain/member/domain/ProfileImg.java b/src/main/java/umc/cockple/demo/domain/member/domain/ProfileImg.java index 0868d4c8b..80cef2c81 100644 --- a/src/main/java/umc/cockple/demo/domain/member/domain/ProfileImg.java +++ b/src/main/java/umc/cockple/demo/domain/member/domain/ProfileImg.java @@ -15,12 +15,17 @@ public class ProfileImg { private Long id; @OneToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "member_id") + @JoinColumn(name = "member_id", unique = true) private Member member; @Column(nullable = false) private String imgKey; + // 동시 수정 시 lost update 방지를 위한 낙관적 락 버전 + @Version + @Column(nullable = false) + private Long version; + public void setMember(Member member) { this.member = member; diff --git a/src/main/java/umc/cockple/demo/domain/member/exception/MemberErrorCode.java b/src/main/java/umc/cockple/demo/domain/member/exception/MemberErrorCode.java index 3b7451fd7..864664baf 100644 --- a/src/main/java/umc/cockple/demo/domain/member/exception/MemberErrorCode.java +++ b/src/main/java/umc/cockple/demo/domain/member/exception/MemberErrorCode.java @@ -24,6 +24,7 @@ public enum MemberErrorCode implements BaseErrorCode { MANAGER_CANNOT_LEAVE(HttpStatus.BAD_REQUEST, "MEMBER401", "모임장은 탈퇴할 수 없습니다. 모임 삭제를 먼저 해주세요."), SUBMANAGER_CANNOT_LEAVE(HttpStatus.BAD_REQUEST, "MEMBER402", "부모임장은 탈퇴할 수 없습니다. 모임 삭제를 먼저 해주세요."), + PROFILE_UPDATE_CONFLICT(HttpStatus.CONFLICT, "MEMBER403", "프로필 수정 요청이 동시에 처리되었습니다. 잠시 후 다시 시도해주세요."), // 회원 주소 관련 ADDRESS_NOT_FOUND(HttpStatus.NOT_FOUND, "MEM_ADDR201", "해당 주소를 찾을 수 없습니다."), diff --git a/src/main/java/umc/cockple/demo/domain/member/repository/MemberExerciseRepository.java b/src/main/java/umc/cockple/demo/domain/member/repository/MemberExerciseRepository.java index 250596302..9e1627109 100644 --- a/src/main/java/umc/cockple/demo/domain/member/repository/MemberExerciseRepository.java +++ b/src/main/java/umc/cockple/demo/domain/member/repository/MemberExerciseRepository.java @@ -17,6 +17,9 @@ public interface MemberExerciseRepository extends JpaRepository findByExerciseAndMember(Exercise exercise, Member member); @Query(""" diff --git a/src/main/java/umc/cockple/demo/domain/member/repository/MemberPartyRepository.java b/src/main/java/umc/cockple/demo/domain/member/repository/MemberPartyRepository.java index 38ef173d6..589721edf 100644 --- a/src/main/java/umc/cockple/demo/domain/member/repository/MemberPartyRepository.java +++ b/src/main/java/umc/cockple/demo/domain/member/repository/MemberPartyRepository.java @@ -57,4 +57,7 @@ List findAllPartyIdsByMemberAndPartyIds(@Param("memberId") Long memberId, List findAllByPartyIdWithMember(@Param("partyId") Long partyId); Optional findByPartyIdAndRole(Long partyId, Role role); + + // 프로필의 가입 모임 수. 컬렉션 전량 로딩 없이 COUNT만 수행한다. + long countByMember_Id(Long memberId); } diff --git a/src/main/java/umc/cockple/demo/domain/member/repository/MemberRepository.java b/src/main/java/umc/cockple/demo/domain/member/repository/MemberRepository.java index 92a2f4d11..89edc34ef 100644 --- a/src/main/java/umc/cockple/demo/domain/member/repository/MemberRepository.java +++ b/src/main/java/umc/cockple/demo/domain/member/repository/MemberRepository.java @@ -1,6 +1,7 @@ package umc.cockple.demo.domain.member.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import umc.cockple.demo.domain.member.domain.Member; @@ -50,4 +51,13 @@ SELECT new map(m.id as id, m.memberName as name) FROM Member m """) Optional findMemberWithProfileById(@Param("memberId") Long memberId); + /** 토큰 버전 원자적 증가 (탈퇴/재사용 탐지 시 발급된 모든 토큰 무효화) */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("UPDATE Member m SET m.tokenVersion = m.tokenVersion + 1 WHERE m.id = :memberId") + void incrementTokenVersion(@Param("memberId") Long memberId); + + /** 토큰 버전만 조회 (Redis 캐시 miss 시 SoT fallback) */ + @Query("SELECT m.tokenVersion FROM Member m WHERE m.id = :memberId") + Optional findTokenVersionById(@Param("memberId") Long memberId); + } diff --git a/src/main/java/umc/cockple/demo/domain/member/service/MemberCommandService.java b/src/main/java/umc/cockple/demo/domain/member/service/MemberCommandService.java index 9142e8f32..5ccdbead3 100644 --- a/src/main/java/umc/cockple/demo/domain/member/service/MemberCommandService.java +++ b/src/main/java/umc/cockple/demo/domain/member/service/MemberCommandService.java @@ -16,7 +16,8 @@ import umc.cockple.demo.domain.member.events.MemberWithdrawnEvent; import umc.cockple.demo.domain.member.repository.*; import umc.cockple.demo.domain.member.enums.MemberStatus; -import umc.cockple.demo.domain.file.service.FileService; +import umc.cockple.demo.domain.file.service.ObjectStorageDeleteOutboxService; +import umc.cockple.demo.global.auth.TokenVersionRepository; import java.time.LocalDate; import java.time.LocalTime; @@ -40,7 +41,8 @@ public class MemberCommandService { private final ApplicationEventPublisher applicationEventPublisher; private final KakaoOauthService kakaoOauthService; - private final FileService fileService; + private final ObjectStorageDeleteOutboxService objectStorageDeleteOutboxService; + private final TokenVersionRepository tokenVersionRepository; // ==================== 회원 관련 =================== @@ -49,6 +51,9 @@ public void memberDetailInfo(Long memberId, MemberDetailInfoRequestDTO requestDT // 회원 찾기 Member member = findByMemberId(memberId); + // 기존 키워드 삭제 + memberKeywordRepository.deleteAllByMember(member); + // 키워드 저장 List keywords = requestDTO.keywords().stream() .map(keyword -> { @@ -65,18 +70,14 @@ public void memberDetailInfo(Long memberId, MemberDetailInfoRequestDTO requestDT memberKeywordRepository.saveAll(keywords); - if (StringUtils.hasText(requestDTO.imgKey())) { - ProfileImg profile = ProfileImg.builder() - .member(member) - .imgKey(requestDTO.imgKey()) - .build(); - - member.updateMemberFirst(requestDTO, keywords, profile); + // 회원 정보 수정 (프로필 사진 제외) + member.updateMemberFirst(requestDTO, keywords); - } else { - member.updateMemberFirst(requestDTO, keywords); + // 프로필 사진은 updateProfile 과 동일한 도메인 메서드로 처리 + if (StringUtils.hasText(requestDTO.imgKey())) { + member.changeProfileImage(requestDTO.imgKey()) + .ifPresent(oldKey -> objectStorageDeleteOutboxService.enqueueProfileImage(member.getId(), oldKey)); } - } public void withdrawMember(Long memberId) { @@ -94,8 +95,12 @@ public void withdrawMember(Long memberId) { // 카카오 연결 끊기 kakaoOauthService.unlinkAccess(member); - // 활성화 여부 해제, 리프레시 토큰 삭제 + // 활성화 여부 해제 member.withdraw(); + + // 토큰 버전 증가 - 발급된 모든 토큰(AT/RT)을 즉시 무효화 + tokenVersionRepository.increment(member.getId()); + applicationEventPublisher.publishEvent(MemberWithdrawnEvent.withdrawn(member.getId())); } @@ -125,40 +130,13 @@ public void updateProfile(UpdateProfileRequestDTO requestDto, Long memberId) { memberKeywordRepository.saveAll(keywords); - log.info("===== 프로필 이미지 key값 확인 : " + requestDto.imgKey()); - - // 이미지 -> 저장 후 url 받아오기 - String imgKey = requestDto.imgKey(); - - // 받은 key가 null인지 확인 - if (!StringUtils.hasText(imgKey)) { - member.updateMember(requestDto, keywords); - } else { - - ProfileImg profile = member.getProfileImg(); - // 기존 이미지 존재시 이미지 새로 업로드 - if (profile != null) { - - // 프로필 사진이 변경되었을 경우에만 이미지 url 변경 및 S3 사진 변경 - if (!profile.getImgKey().equals(imgKey)) { - fileService.delete(profile.getImgKey()); - profile.updateProfile(imgKey); - } - - // 회원 정보 수정하기 (프로필 사진 제외) - member.updateMember(requestDto, keywords); - - } else { - // 받아온 이미지로 profile객체 생성 - ProfileImg img = ProfileImg.builder() - .member(member) - .imgKey(imgKey) - .build(); - - // 회원 정보 수정하기 (프로필 사진까지) - member.updateMember(requestDto, keywords, img); + // 회원 정보 수정 (프로필 사진 제외) + member.updateMember(requestDto, keywords); - } + // 프로필 사진 신규 등록/교체는 도메인 메서드로 위임. 교체 시 정리 대상 key를 받아 outbox 에 등록 + if (StringUtils.hasText(requestDto.imgKey())) { + member.changeProfileImage(requestDto.imgKey()) + .ifPresent(oldKey -> objectStorageDeleteOutboxService.enqueueProfileImage(memberId, oldKey)); } chatRoomMemberRepository.findDirectChatCounterParts(member.getId()) diff --git a/src/main/java/umc/cockple/demo/domain/member/service/MemberProfileUpdateExecutor.java b/src/main/java/umc/cockple/demo/domain/member/service/MemberProfileUpdateExecutor.java new file mode 100644 index 000000000..c7feaac46 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/member/service/MemberProfileUpdateExecutor.java @@ -0,0 +1,77 @@ +package umc.cockple.demo.domain.member.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.hibernate.exception.ConstraintViolationException; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.stereotype.Component; +import umc.cockple.demo.domain.member.dto.MemberDetailInfoRequestDTO; +import umc.cockple.demo.domain.member.dto.UpdateProfileRequestDTO; +import umc.cockple.demo.domain.member.exception.MemberErrorCode; +import umc.cockple.demo.domain.member.exception.MemberException; + +/** + * 프로필을 쓰는 작업(온보딩 등록 / 프로필 수정)의 동시성 충돌을 트랜잭션 밖에서 재시도해 멱등하게 만든다. + * + * 신규 등록 동시 진입 -> 패배 트랜잭션이 unique 제약 위반 + * 기존 사진 교체 동시 진입 -> 패배 트랜잭션이 낙관적 락 실패 + * + * 두 경우 모두 재시도하면 프로필이 이미 존재/최신 버전이므로 UPDATE 경로로 수렴해 성공한다. + * (재시도가 트랜잭션 경계 밖에서 일어나야 매번 새 트랜잭션/영속성 컨텍스트가 열린다.) + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class MemberProfileUpdateExecutor { + + private static final int MAX_ATTEMPTS = 3; + + private static final String PROFILE_IMG_UNIQUE_CONSTRAINT = "uq_profile_img_member"; + + private final MemberCommandService memberCommandService; + + public void registerMemberDetailInfo(Long memberId, MemberDetailInfoRequestDTO requestDto) { + runWithConflictRetry(memberId, () -> memberCommandService.memberDetailInfo(memberId, requestDto)); + } + + public void updateProfile(UpdateProfileRequestDTO requestDto, Long memberId) { + runWithConflictRetry(memberId, () -> memberCommandService.updateProfile(requestDto, memberId)); + } + + private void runWithConflictRetry(Long memberId, Runnable operation) { + for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + try { + operation.run(); + return; + } catch (ObjectOptimisticLockingFailureException e) { + // 낙관적 락 실패 = 프로필 사진 동시 교체 (ProfileImg 만 @Version 보유) -> 재시도 + log.warn("프로필 동시성 충돌(낙관적 락) - memberId: {}, 시도: {}/{}", memberId, attempt, MAX_ATTEMPTS); + } catch (DataIntegrityViolationException e) { + // 무결성 위반 중 '프로필 이미지 유니크' 만 동시성 충돌로 간주, 그 외(NOT NULL/FK 등)는 진짜 에러로 전파 + if (!isProfileImageUniqueViolation(e)) { + throw e; + } + log.warn("프로필 동시성 충돌 - memberId: {}, 시도: {}/{}", memberId, attempt, MAX_ATTEMPTS); + } + } + throw new MemberException(MemberErrorCode.PROFILE_UPDATE_CONFLICT); + } + + + private boolean isProfileImageUniqueViolation(Throwable e) { + for (Throwable t = e; t != null && t != t.getCause(); t = t.getCause()) { + if (t instanceof ConstraintViolationException cve) { + String name = cve.getConstraintName(); + if (name != null && name.toLowerCase().contains(PROFILE_IMG_UNIQUE_CONSTRAINT)) { + return true; + } + } + String message = t.getMessage(); + if (message != null && message.toLowerCase().contains(PROFILE_IMG_UNIQUE_CONSTRAINT)) { + return true; + } + } + return false; + } +} diff --git a/src/main/java/umc/cockple/demo/domain/member/service/MemberQueryService.java b/src/main/java/umc/cockple/demo/domain/member/service/MemberQueryService.java index 1dfb83067..4cde5b26f 100644 --- a/src/main/java/umc/cockple/demo/domain/member/service/MemberQueryService.java +++ b/src/main/java/umc/cockple/demo/domain/member/service/MemberQueryService.java @@ -5,8 +5,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import umc.cockple.demo.domain.chat.dto.MemberConnectionInfo; -import umc.cockple.demo.domain.contest.domain.Contest; -import umc.cockple.demo.domain.contest.enums.MedalType; +import umc.cockple.demo.domain.contest.dto.ContestMedalSummaryDTO; +import umc.cockple.demo.domain.contest.service.ContestQueryService; import umc.cockple.demo.domain.file.service.FileService; import umc.cockple.demo.domain.member.converter.MemberConverter; import umc.cockple.demo.domain.member.domain.Member; @@ -19,13 +19,14 @@ import umc.cockple.demo.domain.member.dto.OnboardingStatusResponseDTO; import umc.cockple.demo.domain.member.exception.MemberErrorCode; import umc.cockple.demo.domain.member.exception.MemberException; +import umc.cockple.demo.domain.member.repository.MemberExerciseRepository; +import umc.cockple.demo.domain.member.repository.MemberPartyRepository; import umc.cockple.demo.domain.member.repository.MemberRepository; import umc.cockple.demo.global.enums.Keyword; import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; +import static umc.cockple.demo.domain.contest.dto.ContestMedalSummaryDTO.*; import static umc.cockple.demo.domain.member.converter.MemberConverter.*; @Service @@ -35,6 +36,9 @@ public class MemberQueryService { private final MemberRepository memberRepository; + private final MemberPartyRepository memberPartyRepository; + private final MemberExerciseRepository memberExerciseRepository; + private final ContestQueryService contestQueryService; private final FileService fileService; /* @@ -57,8 +61,8 @@ public GetMyProfileResponseDTO getMyProfile(Long memberId) { // 대표 주소 추출 MemberAddr memberAddr = findMainAddress(member); - // 운동 개수 추출 - int exerciseCnt = member.getMemberExercises().size(); + // 운동 개수 추출 (컬렉션 로딩 없이 COUNT) + int exerciseCnt = (int) memberExerciseRepository.countByMember_Id(memberId); // 엔티티 -> 값 타입으로 변환 List keywords = member.getKeywords().stream() @@ -80,15 +84,14 @@ public GetProfileResponseDTO getProfile(Long memberId) { imgUrl = fileService.getUrlFromKey(member.getProfileImg().getImgKey()); } - // 각 메달 개수 카운트 - Map counts = member.getContests().stream() - .collect(Collectors.groupingBy(Contest::getMedalType, Collectors.counting())); + // 메달 개수 - contest 도메인 API로 집계 (컬렉션 로딩 없이 집계 쿼리) + Response medals = contestQueryService.getMyMedalSummary(memberId); - int goldMedal = counts.getOrDefault(MedalType.GOLD, 0L).intValue(); - int silverMedal = counts.getOrDefault(MedalType.SILVER, 0L).intValue(); - int bronzeMedal = counts.getOrDefault(MedalType.BRONZE, 0L).intValue(); + // 가입 모임 수 - 컬렉션 로딩 없이 COUNT + int partyCnt = (int) memberPartyRepository.countByMember_Id(memberId); - return memberToGetProfileResponseDTO(member, goldMedal, silverMedal, bronzeMedal, imgUrl); + return memberToGetProfileResponseDTO(member, partyCnt, + medals.goldCount(), medals.silverCount(), medals.bronzeCount(), imgUrl); } /* diff --git a/src/main/java/umc/cockple/demo/domain/member/service/support/MemberLookupService.java b/src/main/java/umc/cockple/demo/domain/member/service/support/MemberLookupService.java new file mode 100644 index 000000000..29588a4d9 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/member/service/support/MemberLookupService.java @@ -0,0 +1,42 @@ +package umc.cockple.demo.domain.member.service.support; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.domain.MemberAddr; +import umc.cockple.demo.domain.member.exception.MemberErrorCode; +import umc.cockple.demo.domain.member.exception.MemberException; +import umc.cockple.demo.domain.member.repository.MemberRepository; + +import java.util.Map; +import java.util.Set; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class MemberLookupService { + + private final MemberRepository memberRepository; + + public Member findByIdOrThrow(Long memberId) { + return memberRepository.findById(memberId) + .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + } + + public Member findWithAddressesOrThrow(Long memberId) { + return memberRepository.findMemberWithAddresses(memberId) + .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + } + + public MemberAddr findMainAddressOrThrow(Member member) { + return member.getAddresses().stream() + .filter(MemberAddr::getIsMain) + .findFirst() + .orElseThrow(() -> new MemberException(MemberErrorCode.MAIN_ADDRESS_NULL)); + } + + public Map findNamesByIds(Set memberIds) { + return memberRepository.findMemberNamesByIds(memberIds); + } +} diff --git a/src/main/java/umc/cockple/demo/domain/notification/events/NotificationEventListener.java b/src/main/java/umc/cockple/demo/domain/notification/events/NotificationEventListener.java index 8af12a451..9b0a772f0 100644 --- a/src/main/java/umc/cockple/demo/domain/notification/events/NotificationEventListener.java +++ b/src/main/java/umc/cockple/demo/domain/notification/events/NotificationEventListener.java @@ -21,7 +21,7 @@ public class NotificationEventListener { private final MemberRepository memberRepository; @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - @Async + @Async("notificationExecutor") @Transactional(readOnly = true, propagation = Propagation.REQUIRES_NEW) public void handleNotification(NotificationEvent event) { log.info("[NOTIFICATION] FCM 전송 이벤트 처리 - memberId: {}", event.memberId()); diff --git a/src/main/java/umc/cockple/demo/domain/notification/fcm/FcmService.java b/src/main/java/umc/cockple/demo/domain/notification/fcm/FcmService.java index ff1f9e716..30e0ab310 100644 --- a/src/main/java/umc/cockple/demo/domain/notification/fcm/FcmService.java +++ b/src/main/java/umc/cockple/demo/domain/notification/fcm/FcmService.java @@ -8,6 +8,7 @@ import com.google.firebase.messaging.WebpushFcmOptions; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import umc.cockple.demo.domain.chat.enums.ChatRoomType; @@ -24,6 +25,9 @@ public class FcmService { private final MemberRepository memberRepository; private final FirebaseMessaging firebaseMessaging; + @Value("${fcm.fake-latency-ms:0}") + private long fakeLatencyMs; + @Transactional public void registerFcmToken(Long memberId, String fcmToken) { Member member = memberRepository.findById(memberId) @@ -33,6 +37,10 @@ public void registerFcmToken(Long memberId, String fcmToken) { } public void sendNotification(Member member, String title, String content) { + if (applyFakeLatencyAndSkip(member.getId())) { + return; + } + String fcmToken = member.getFcmToken(); if (fcmToken == null || fcmToken.isBlank()) { log.info("FCM 토큰 없음 - memberId: {}, 알림 전송 생략", member.getId()); @@ -58,6 +66,10 @@ public void sendNotification(Member member, String title, String content) { public void sendChatNotification(Member member, String title, String content, Long chatRoomId, ChatRoomType chatRoomType) { + if (applyFakeLatencyAndSkip(member.getId())) { + return; + } + String fcmToken = member.getFcmToken(); if (fcmToken == null || fcmToken.isBlank()) { log.info("FCM 토큰 없음 - memberId: {}, 채팅 알림 전송 생략", member.getId()); @@ -86,6 +98,22 @@ public void sendChatNotification(Member member, String title, String content, } } + /** + * 부하테스트용 FCM 지연 주입. + */ + private boolean applyFakeLatencyAndSkip(Long memberId) { + if (fakeLatencyMs <= 0) { + return false; + } + try { + Thread.sleep(fakeLatencyMs); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + log.info("[FCM][FAKE] 지연주입 {}ms 후 실전송 스킵 - memberId: {}", fakeLatencyMs, memberId); + return true; + } + /** * 채팅 알림 클릭 시 이동할 경로(상대경로)를 생성한다. * webpush.fcm_options.link 로 전달되어 FCM이 클릭 시 네이티브로 이동시킨다. diff --git a/src/main/java/umc/cockple/demo/domain/notification/repository/NotificationRepository.java b/src/main/java/umc/cockple/demo/domain/notification/repository/NotificationRepository.java index de619694d..b0421fde2 100644 --- a/src/main/java/umc/cockple/demo/domain/notification/repository/NotificationRepository.java +++ b/src/main/java/umc/cockple/demo/domain/notification/repository/NotificationRepository.java @@ -15,6 +15,8 @@ public interface NotificationRepository extends JpaRepository findAllByMemberOrderByCreatedAtDesc(Member member); + boolean existsByMember_IdAndIsReadFalse(Long memberId); + Optional findFirstByMemberAndTypeNotOrderByCreatedAtAsc(Member member, NotificationType type); @Modifying diff --git a/src/main/java/umc/cockple/demo/domain/notification/service/NotificationQueryService.java b/src/main/java/umc/cockple/demo/domain/notification/service/NotificationQueryService.java index baa08008d..727820f73 100644 --- a/src/main/java/umc/cockple/demo/domain/notification/service/NotificationQueryService.java +++ b/src/main/java/umc/cockple/demo/domain/notification/service/NotificationQueryService.java @@ -49,23 +49,10 @@ public List getAllNotifications(Long memberId) { public ExistNewNotificationResponseDTO checkUnreadNotification(Long memberId) { - Member member = findByMemberId(memberId); - - List notifications = member.getNotifications(); - - long count = notifications.stream() - .filter(notification -> notification.getIsRead().equals(false)) - .count(); - - if (count > 0) { - return ExistNewNotificationResponseDTO.builder() - .existNewNotification(true) - .build(); - } else { - return ExistNewNotificationResponseDTO.builder() - .existNewNotification(false) - .build(); - } + boolean existNewNotification = notificationRepository.existsByMember_IdAndIsReadFalse(memberId); + return ExistNewNotificationResponseDTO.builder() + .existNewNotification(existNewNotification) + .build(); } diff --git a/src/main/java/umc/cockple/demo/domain/party/service/support/PartyLookupService.java b/src/main/java/umc/cockple/demo/domain/party/service/support/PartyLookupService.java new file mode 100644 index 000000000..e1af00ab9 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/party/service/support/PartyLookupService.java @@ -0,0 +1,22 @@ +package umc.cockple.demo.domain.party.service.support; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import umc.cockple.demo.domain.party.domain.Party; +import umc.cockple.demo.domain.party.exception.PartyErrorCode; +import umc.cockple.demo.domain.party.exception.PartyException; +import umc.cockple.demo.domain.party.repository.PartyRepository; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class PartyLookupService { + + private final PartyRepository partyRepository; + + public Party findByIdWithLevelsOrThrow(Long partyId) { + return partyRepository.findByIdWithLevels(partyId) + .orElseThrow(() -> new PartyException(PartyErrorCode.PARTY_NOT_FOUND)); + } +} diff --git a/src/main/java/umc/cockple/demo/global/auth/RefreshTokenRepository.java b/src/main/java/umc/cockple/demo/global/auth/RefreshTokenRepository.java index c6bb9dc83..86cc1ddfa 100644 --- a/src/main/java/umc/cockple/demo/global/auth/RefreshTokenRepository.java +++ b/src/main/java/umc/cockple/demo/global/auth/RefreshTokenRepository.java @@ -2,9 +2,11 @@ import lombok.RequiredArgsConstructor; import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.RedisScript; import org.springframework.stereotype.Repository; import umc.cockple.demo.global.jwt.properties.JwtProperties; +import java.util.List; import java.util.Optional; import java.util.concurrent.TimeUnit; @@ -13,6 +15,21 @@ public class RefreshTokenRepository { private static final String KEY_PREFIX = "refresh:"; + private static final String CONSUMED_PREFIX = "refresh:consumed:"; + + /**. + * KEYS[1]=활성키, KEYS[2]=소비 마커키, ARGV[1]=grace TTL(ms) + * @return memberId 또는 nil. + */ + private static final RedisScript CONSUME_AND_MARK_SCRIPT = RedisScript.of(""" + local memberId = redis.call('GET', KEYS[1]) + if not memberId then + return nil + end + redis.call('DEL', KEYS[1]) + redis.call('SET', KEYS[2], memberId, 'PX', ARGV[1]) + return memberId + """, String.class); private final StringRedisTemplate stringRedisTemplate; private final JwtProperties jwtProperties; @@ -26,13 +43,24 @@ public void save(String refreshToken, Long memberId) { ); } - public Optional findAndDeleteByToken(String refreshToken) { - String value = stringRedisTemplate.opsForValue().getAndDelete(KEY_PREFIX + refreshToken); - if (value == null) return Optional.empty(); - return Optional.of(Long.valueOf(value)); + public Optional consumeAndMark(String refreshToken) { + String memberId = stringRedisTemplate.execute( + CONSUME_AND_MARK_SCRIPT, + List.of(KEY_PREFIX + refreshToken, CONSUMED_PREFIX + refreshToken), + String.valueOf(jwtProperties.getRefreshTokenReuseGrace()) + ); + return memberId == null ? Optional.empty() : Optional.of(Long.valueOf(memberId)); } public void delete(String refreshToken) { stringRedisTemplate.delete(KEY_PREFIX + refreshToken); } + + /** + * 해당 토큰이 grace window 이내에 정상 소비된 이력이 있는지 여부 + * true 이면 정상 경쟁/재시도, false 이면 grace 를 지난 재사용(탈취 의심) + */ + public boolean isRecentlyConsumed(String refreshToken) { + return Boolean.TRUE.equals(stringRedisTemplate.hasKey(CONSUMED_PREFIX + refreshToken)); + } } diff --git a/src/main/java/umc/cockple/demo/global/auth/TokenVersionRepository.java b/src/main/java/umc/cockple/demo/global/auth/TokenVersionRepository.java new file mode 100644 index 000000000..a5736d2db --- /dev/null +++ b/src/main/java/umc/cockple/demo/global/auth/TokenVersionRepository.java @@ -0,0 +1,46 @@ +package umc.cockple.demo.global.auth; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import umc.cockple.demo.domain.member.repository.MemberRepository; + +@Repository +@RequiredArgsConstructor +public class TokenVersionRepository { + + private static final String KEY_PREFIX = "member:tokenVersion:"; + + private final StringRedisTemplate stringRedisTemplate; + private final MemberRepository memberRepository; + + /** + * 현재 토큰 버전을 반환한다. + * 캐시 hit 시 Redis, miss 시 DB에서 읽어 재적재 + */ + public long getVersion(Long memberId) { + String cached = stringRedisTemplate.opsForValue().get(KEY_PREFIX + memberId); + if (cached != null) { + return Long.parseLong(cached); + } + long version = memberRepository.findTokenVersionById(memberId).orElse(0L); + cache(memberId, version); + return version; + } + + /** + * 토큰 버전을 1 증가시키고 새 값을 반환 + */ + @Transactional + public long increment(Long memberId) { + memberRepository.incrementTokenVersion(memberId); + long newVersion = memberRepository.findTokenVersionById(memberId).orElse(0L); + cache(memberId, newVersion); + return newVersion; + } + + private void cache(Long memberId, long version) { + stringRedisTemplate.opsForValue().set(KEY_PREFIX + memberId, String.valueOf(version)); + } +} diff --git a/src/main/java/umc/cockple/demo/global/config/AGENTS.md b/src/main/java/umc/cockple/demo/global/config/AGENTS.md index a2bb68b13..bff8671d9 100644 --- a/src/main/java/umc/cockple/demo/global/config/AGENTS.md +++ b/src/main/java/umc/cockple/demo/global/config/AGENTS.md @@ -3,19 +3,18 @@ Apply parent guides first. This file only covers `global/config/`. ## OVERVIEW -This package is the runtime bootstrap surface for security, WebSocket, Redis/cache, Firebase, Swagger, QueryDSL, async work, and external storage wiring. +This package is the shared runtime bootstrap surface for security, Redis/cache, Firebase, Swagger, QueryDSL, async work, and external storage wiring. ## WHERE TO LOOK | Task | Location | Notes | |------|----------|-------| | Security whitelist + CORS | `SecurityConfig.java` | explicit public endpoints and allowed origins | -| WebSocket bootstrap | `WebSocketConfig.java` | binds `/ws/chats` to handler + JWT interceptor | | Redis/cache serialization | `RedisConfig.java` | connection factory, templates, cache manager | | Firebase init | `FirebaseConfig.java` | disabled for `integrationtest` profile | ## CONVENTIONS - Runtime values belong in `application*.yml`; config classes wire beans around those values. -- `SecurityConfig` and `WebSocketConfig` both carry explicit frontend origin lists. +- `SecurityConfig` carries explicit HTTP frontend origin lists. Chat WebSocket origins live in `domain/chat/presentation/websocket/ChatWebSocketConfig.java`. - Firebase is suppressed during integration tests and mocked from test config. - Redis serialization uses a permissive polymorphic JSON serializer; cache/template behavior lives here, not in slices. diff --git a/src/main/java/umc/cockple/demo/global/config/AsyncConfig.java b/src/main/java/umc/cockple/demo/global/config/AsyncConfig.java index 50d862fcc..9e77abc83 100644 --- a/src/main/java/umc/cockple/demo/global/config/AsyncConfig.java +++ b/src/main/java/umc/cockple/demo/global/config/AsyncConfig.java @@ -1,9 +1,110 @@ package umc.cockple.demo.global.config; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration; +import org.springframework.boot.task.ThreadPoolTaskExecutorBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.TaskDecorator; import org.springframework.scheduling.annotation.EnableAsync; -import org.springframework.stereotype.Component; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import umc.cockple.demo.global.logging.MdcTaskDecorator; -@Component +import java.util.concurrent.RejectedExecutionHandler; +import java.util.concurrent.ThreadPoolExecutor; + +/** + * 지연 특성별로 벌크헤드(bulkhead) 격리 + * {@code chatExecutor} — 실시간/저지연 + * {@code notificationExecutor} — 외부 FCM HTTP 호출 + * {@code applicationTaskExecutor} — qualifier 없는 @Async의 안전한 기본 착지점 + */ +@Configuration @EnableAsync +@Slf4j public class AsyncConfig { + + /** Spring이 qualifier 없는 @Async의 기본 executor를 찾는 이름. */ + private static final String DEFAULT_TASK_EXECUTOR_BEAN_NAME = "taskExecutor"; + + @Bean + public TaskDecorator mdcTaskDecorator() { + return new MdcTaskDecorator(); + } + + @Bean(name = { + TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME, + DEFAULT_TASK_EXECUTOR_BEAN_NAME + }) + public ThreadPoolTaskExecutor applicationTaskExecutor( + ThreadPoolTaskExecutorBuilder builder, + TaskDecorator mdcTaskDecorator + ) { + return builder + .threadNamePrefix("cockple-async-") + .taskDecorator(mdcTaskDecorator) + .build(); + } + + /** + * 실시간 채팅용 풀-> 짧은 내부 I/O 작업을 저지연으로 처리 + */ + @Bean("chatExecutor") + public ThreadPoolTaskExecutor chatExecutor( + @Value("${async.chat.core-size:8}") int coreSize, + @Value("${async.chat.max-size:16}") int maxSize, + @Value("${async.chat.queue-capacity:500}") int queueCapacity, + @Value("${async.chat.await-termination-seconds:30}") int awaitTerminationSeconds, + TaskDecorator mdcTaskDecorator + ) { + // 채팅은 유실 방지가 우선 -> 포화 시 호출 스레드에서 실행해 백프레셔를 건다. + return buildExecutor("cockple-chat-", coreSize, maxSize, queueCapacity, + awaitTerminationSeconds, mdcTaskDecorator, new ThreadPoolExecutor.CallerRunsPolicy()); + } + + /** + * FCM 알림용 풀. 외부 Firebase HTTP 호출을 채팅과 격리 + * 큐를 크게 잡되 유한하게 두어 Firebase 장애 시 무한 백로그(OOM)를 방지 + */ + @Bean("notificationExecutor") + public ThreadPoolTaskExecutor notificationExecutor( + @Value("${async.notification.core-size:4}") int coreSize, + @Value("${async.notification.max-size:12}") int maxSize, + @Value("${async.notification.queue-capacity:1000}") int queueCapacity, + @Value("${async.notification.await-termination-seconds:30}") int awaitTerminationSeconds, + TaskDecorator mdcTaskDecorator + ) { + + return buildExecutor("cockple-noti-", coreSize, maxSize, queueCapacity, + awaitTerminationSeconds, mdcTaskDecorator, logAndDiscardPolicy()); + } + + /** + * 큐/스레드 포화로 거부된 작업을 예외 전파 없이 버림 + */ + private RejectedExecutionHandler logAndDiscardPolicy() { + // TODO: 알림 재전송 도입 시, 여기서 drop 대신 outbox/DLQ에 적재해 재시도 훅으로 사용 + return (runnable, executor) -> + log.warn("[NOTIFICATION] 알림 풀 포화로 작업 drop - poolSize={}, activeCount={}, queueSize={}", + executor.getPoolSize(), executor.getActiveCount(), executor.getQueue().size()); + } + + private ThreadPoolTaskExecutor buildExecutor(String threadNamePrefix, int coreSize, int maxSize, + int queueCapacity, int awaitTerminationSeconds, + TaskDecorator mdcTaskDecorator, + RejectedExecutionHandler rejectedExecutionHandler) { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(coreSize); + executor.setMaxPoolSize(maxSize); + executor.setQueueCapacity(queueCapacity); + executor.setThreadNamePrefix(threadNamePrefix); + executor.setTaskDecorator(mdcTaskDecorator); + executor.setRejectedExecutionHandler(rejectedExecutionHandler); + // graceful shutdown: 진행 중인 작업이 끝날 때까지 대기 + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(awaitTerminationSeconds); + // 스프링이 빈 초기화 시 afterPropertiesSet()으로 initialize()를 호출한다. + return executor; + } } diff --git a/src/main/java/umc/cockple/demo/global/config/SecurityConfig.java b/src/main/java/umc/cockple/demo/global/config/SecurityConfig.java index d6b0a6579..61b2c3179 100644 --- a/src/main/java/umc/cockple/demo/global/config/SecurityConfig.java +++ b/src/main/java/umc/cockple/demo/global/config/SecurityConfig.java @@ -25,6 +25,7 @@ public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; private final RestAuthenticationEntryPoint restEntryPoint; + private final WebProperties webProperties; @Bean @@ -66,7 +67,9 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); - config.setAllowedOrigins(List.of("http://localhost:5173", "https://cockple.store", "https://www.cockple.store", "https://staging.cockple.store", "https://cockple-fe.vercel.app")); // 배포 시에는 도메인 지정 권장 + // 허용 Origin은 WebProperties(SSOT)에서 주입. 정확 매칭 유지 — allowCredentials(true)와 + // origin 패턴/와일드카드를 함께 쓰면 요청 Origin이 반사되어 심각한 취약점이 되므로 절대 금지. + config.setAllowedOrigins(webProperties.getAllowedOrigins()); config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); config.setAllowedHeaders(List.of("*")); config.setAllowCredentials(true); diff --git a/src/main/java/umc/cockple/demo/global/config/WebProperties.java b/src/main/java/umc/cockple/demo/global/config/WebProperties.java new file mode 100644 index 000000000..3b1ce7634 --- /dev/null +++ b/src/main/java/umc/cockple/demo/global/config/WebProperties.java @@ -0,0 +1,27 @@ +package umc.cockple.demo.global.config; + +import jakarta.validation.constraints.NotEmpty; +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; +import org.springframework.validation.annotation.Validated; + +import java.util.List; + +/** + * 도메인/오리진 관련 웹 설정의 단일 소스(SSOT) + * 값은 application.yml의 기본값 또는 환경변수(CORS_ALLOWED_ORIGINS, COOKIE_DOMAIN)로 주입 + */ +@Getter +@Setter +@Configuration +@Validated +@ConfigurationProperties(prefix = "cockple.web") +public class WebProperties { + + @NotEmpty(message = "cockple.web.allowed-origins must not be empty") + private List allowedOrigins; + + private String cookieDomain; +} diff --git a/src/main/java/umc/cockple/demo/global/jwt/domain/JwtTokenProvider.java b/src/main/java/umc/cockple/demo/global/jwt/domain/JwtTokenProvider.java index 020aeabc0..4d6955e79 100644 --- a/src/main/java/umc/cockple/demo/global/jwt/domain/JwtTokenProvider.java +++ b/src/main/java/umc/cockple/demo/global/jwt/domain/JwtTokenProvider.java @@ -10,10 +10,8 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.stereotype.Component; -import umc.cockple.demo.domain.member.domain.Member; import umc.cockple.demo.domain.member.exception.MemberErrorCode; import umc.cockple.demo.domain.member.exception.MemberException; -import umc.cockple.demo.domain.member.repository.MemberRepository; import umc.cockple.demo.global.jwt.properties.JwtProperties; import umc.cockple.demo.global.security.domain.CustomUserDetails; @@ -25,9 +23,13 @@ @RequiredArgsConstructor public class JwtTokenProvider { + /** 토큰 용도 구분 claim (access ↔ refresh 상호 오용 방지) */ + public static final String TOKEN_TYPE_ACCESS = "access"; + public static final String TOKEN_TYPE_REFRESH = "refresh"; + private static final String CLAIM_TYPE = "type"; + private Key key; private final JwtProperties jwtProperties; - private final MemberRepository memberRepository; @PostConstruct public void init() { @@ -35,19 +37,19 @@ public void init() { this.key = Keys.hmacShaKeyFor(keyBytes); } - public String createAccessToken(Long memberId, String nickname) { - return createToken(memberId, nickname, jwtProperties.getAccessTokenValidity()); + public String createAccessToken(Long memberId, String nickname, long tokenVersion) { + return createToken(memberId, nickname, tokenVersion, jwtProperties.getAccessTokenValidity(), TOKEN_TYPE_ACCESS); } - public String createRefreshToken(Long memberId, String nickname) { - return createToken(memberId, nickname, jwtProperties.getRefreshTokenValidity()); + public String createRefreshToken(Long memberId, String nickname, long tokenVersion) { + return createToken(memberId, nickname, tokenVersion, jwtProperties.getRefreshTokenValidity(), TOKEN_TYPE_REFRESH); } - public String createDevToken(Long memberId, String nickname) { - return createToken(memberId, nickname, 1209600000L * 2); + public String createDevToken(Long memberId, String nickname, long tokenVersion) { + return createToken(memberId, nickname, tokenVersion, 1209600000L * 2, TOKEN_TYPE_ACCESS); } - private String createToken(Long memberId, String nickname, long validity) { + private String createToken(Long memberId, String nickname, long tokenVersion, long validity, String type) { Claims claims = Jwts.claims().setSubject(String.valueOf(memberId)); if (nickname == null) { @@ -55,6 +57,8 @@ private String createToken(Long memberId, String nickname, long validity) { } claims.put("nickname", nickname); + claims.put("ver", tokenVersion); + claims.put(CLAIM_TYPE, type); Date now = new Date(); Date expiration = new Date(now.getTime() + validity); @@ -108,21 +112,44 @@ public boolean validateToken(String token) { public Long getUserId(String token) { - Claims claims = Jwts.parserBuilder() + return Long.valueOf(parseClaims(token).getSubject()); + } + + public String getNickname(String token) { + return parseClaims(token).get("nickname", String.class); + } + + public long getTokenVersion(String token) { + Object ver = parseClaims(token).get("ver"); + return ver == null ? 0L : ((Number) ver).longValue(); + } + + public String getTokenType(String token) { + return parseClaims(token).get(CLAIM_TYPE, String.class); + } + + public boolean isAccessToken(String token) { + return TOKEN_TYPE_ACCESS.equals(getTokenType(token)); + } + + public boolean isRefreshToken(String token) { + return TOKEN_TYPE_REFRESH.equals(getTokenType(token)); + } + + private Claims parseClaims(String token) { + return Jwts.parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(token) .getBody(); - - return Long.valueOf(claims.getSubject()); } public Authentication getAuthentication(String token) { - Long memberId = getUserId(token); - Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + Claims claims = parseClaims(token); + Long memberId = Long.valueOf(claims.getSubject()); + String nickname = claims.get("nickname", String.class); - UserDetails userDetails = new CustomUserDetails(member.getId(), member.getNickname()); + UserDetails userDetails = new CustomUserDetails(memberId, nickname); return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities()); } } diff --git a/src/main/java/umc/cockple/demo/global/jwt/properties/JwtProperties.java b/src/main/java/umc/cockple/demo/global/jwt/properties/JwtProperties.java index 13a906fb3..ddf0560e9 100644 --- a/src/main/java/umc/cockple/demo/global/jwt/properties/JwtProperties.java +++ b/src/main/java/umc/cockple/demo/global/jwt/properties/JwtProperties.java @@ -13,4 +13,5 @@ public class JwtProperties { private String secret; private long accessTokenValidity; private long refreshTokenValidity; + private long refreshTokenReuseGrace; } diff --git a/src/main/java/umc/cockple/demo/global/logging/MdcLoggingFilter.java b/src/main/java/umc/cockple/demo/global/logging/MdcLoggingFilter.java new file mode 100644 index 000000000..095208234 --- /dev/null +++ b/src/main/java/umc/cockple/demo/global/logging/MdcLoggingFilter.java @@ -0,0 +1,60 @@ +package umc.cockple.demo.global.logging; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.MDC; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.UUID; + +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +public class MdcLoggingFilter extends OncePerRequestFilter { + + public static final String REQUEST_ID = "requestId"; + public static final String METHOD = "method"; + public static final String URI = "uri"; + public static final String CLIENT_IP = "clientIp"; + private static final String REQUEST_ID_HEADER = "X-Request-Id"; + private static final String FORWARDED_FOR_HEADER = "X-Forwarded-For"; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + try { + MDC.put(REQUEST_ID, resolveRequestId(request)); + MDC.put(METHOD, request.getMethod()); + MDC.put(URI, request.getRequestURI()); + MDC.put(CLIENT_IP, resolveClientIp(request)); + + filterChain.doFilter(request, response); + } finally { + MDC.remove(REQUEST_ID); + MDC.remove(METHOD); + MDC.remove(URI); + MDC.remove(CLIENT_IP); + } + } + + private String resolveRequestId(HttpServletRequest request) { + String requestId = request.getHeader(REQUEST_ID_HEADER); + if (requestId == null || requestId.isBlank()) { + return UUID.randomUUID().toString(); + } + return requestId.trim(); + } + + private String resolveClientIp(HttpServletRequest request) { + String forwardedFor = request.getHeader(FORWARDED_FOR_HEADER); + if (forwardedFor != null && !forwardedFor.isBlank()) { + return forwardedFor.split(",")[0].trim(); + } + return request.getRemoteAddr(); + } +} diff --git a/src/main/java/umc/cockple/demo/global/logging/MdcTaskDecorator.java b/src/main/java/umc/cockple/demo/global/logging/MdcTaskDecorator.java new file mode 100644 index 000000000..dffcd8415 --- /dev/null +++ b/src/main/java/umc/cockple/demo/global/logging/MdcTaskDecorator.java @@ -0,0 +1,32 @@ +package umc.cockple.demo.global.logging; + +import org.slf4j.MDC; +import org.springframework.core.task.TaskDecorator; + +import java.util.Map; + +public class MdcTaskDecorator implements TaskDecorator { + + @Override + public Runnable decorate(Runnable runnable) { + Map parentContext = MDC.getCopyOfContextMap(); + + return () -> { + Map previousContext = MDC.getCopyOfContextMap(); + try { + setContext(parentContext); + runnable.run(); + } finally { + setContext(previousContext); + } + }; + } + + private void setContext(Map context) { + if (context == null || context.isEmpty()) { + MDC.clear(); + return; + } + MDC.setContextMap(context); + } +} diff --git a/src/main/java/umc/cockple/demo/global/oauth2/domain/KakaoClient.java b/src/main/java/umc/cockple/demo/global/oauth2/domain/KakaoClient.java index 4e69943e0..e7ee42db3 100644 --- a/src/main/java/umc/cockple/demo/global/oauth2/domain/KakaoClient.java +++ b/src/main/java/umc/cockple/demo/global/oauth2/domain/KakaoClient.java @@ -1,98 +1,97 @@ -package umc.cockple.demo.global.oauth2.domain; - -import com.fasterxml.jackson.databind.JsonNode; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.stereotype.Component; -import org.springframework.web.reactive.function.BodyInserter; -import org.springframework.web.reactive.function.BodyInserters; -import org.springframework.web.reactive.function.client.WebClient; -import umc.cockple.demo.global.oauth2.domain.info.KakaoClientInfo; - -@Component -@Slf4j -@RequiredArgsConstructor -public class KakaoClient { - - private final WebClient webClient = WebClient.create(); - - @Value("${kakao.client-id}") - private String clientId; - - @Value("${kakao.client-secret:}") - private String clientSecret; - - @Value("${kakao.redirect-uri}") - private String redirectUri; - - @Value("${kakao.token-uri}") - private String tokenUri; - - @Value("${kakao.user-info-uri}") - private String userInfoUri; - - @Value("${kakao.unlink-uri}") - private String unlinkUri; - - @Value("${kakao.admin-key}") - private String adminKey; - - - // 인가코드로 AccessToken 요청하기 - public String getAccessToken(String code) { - BodyInserters.FormInserter formData = BodyInserters.fromFormData("grant_type", "authorization_code") - .with("client_id", clientId) - .with("redirect_uri", redirectUri) - .with("code", code); - - if (clientSecret != null && !clientSecret.isBlank()) { - formData.with("client_secret", clientSecret); - } - - log.info("Redirect URI used: {}", redirectUri); - log.info("Auth Code: {}", code); - JsonNode response = webClient.post() - .uri(tokenUri) - .contentType(MediaType.APPLICATION_FORM_URLENCODED) - .body(formData) - .retrieve() - .bodyToMono(JsonNode.class) - .block(); - - return response.get("access_token").asText(); - } - - // access 토큰으로 사용자 정보 요청 - public KakaoClientInfo getClientInfo(String accessToken) { - JsonNode response = webClient.get() - .uri(userInfoUri) - .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) - .retrieve() - .bodyToMono(JsonNode.class) - .block(); - - Long kakaoId = response.get("id").asLong(); - String nickname = response.path("properties").path("nickname").asText(); - - return new KakaoClientInfo(kakaoId, nickname); - } - - // 회원 탈퇴를 위해 카카오와 연결 끊기 - public void unlinkByAdmin(Long socialId) { - webClient.post() - .uri(unlinkUri) - .header(HttpHeaders.AUTHORIZATION, "KakaoAK " + adminKey) - .contentType(MediaType.APPLICATION_FORM_URLENCODED) - .body(BodyInserters.fromFormData("target_id_type", "user_id") - .with("target_id", String.valueOf(socialId))) - .retrieve() - .toBodilessEntity() - .block() - ; - - log.info("[KAKAO] unlink OK. kakaoUserId={}", socialId); - } -} +package umc.cockple.demo.global.oauth2.domain; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.BodyInserter; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.WebClient; +import umc.cockple.demo.global.oauth2.domain.info.KakaoClientInfo; + +@Component +@Slf4j +@RequiredArgsConstructor +public class KakaoClient { + + private final WebClient webClient = WebClient.create(); + + @Value("${kakao.client-id}") + private String clientId; + + @Value("${kakao.client-secret:}") + private String clientSecret; + + @Value("${kakao.redirect-uri}") + private String redirectUri; + + @Value("${kakao.token-uri}") + private String tokenUri; + + @Value("${kakao.user-info-uri}") + private String userInfoUri; + + @Value("${kakao.unlink-uri}") + private String unlinkUri; + + @Value("${kakao.admin-key}") + private String adminKey; + + + // 인가코드로 AccessToken 요청하기 + public String getAccessToken(String code) { + BodyInserters.FormInserter formData = BodyInserters.fromFormData("grant_type", "authorization_code") + .with("client_id", clientId) + .with("redirect_uri", redirectUri) + .with("code", code); + + if (clientSecret != null && !clientSecret.isBlank()) { + formData.with("client_secret", clientSecret); + } + + log.debug("Redirect URI used for Kakao OAuth token request: {}", redirectUri); + JsonNode response = webClient.post() + .uri(tokenUri) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(formData) + .retrieve() + .bodyToMono(JsonNode.class) + .block(); + + return response.get("access_token").asText(); + } + + // access 토큰으로 사용자 정보 요청 + public KakaoClientInfo getClientInfo(String accessToken) { + JsonNode response = webClient.get() + .uri(userInfoUri) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .retrieve() + .bodyToMono(JsonNode.class) + .block(); + + Long kakaoId = response.get("id").asLong(); + String nickname = response.path("properties").path("nickname").asText(); + + return new KakaoClientInfo(kakaoId, nickname); + } + + // 회원 탈퇴를 위해 카카오와 연결 끊기 + public void unlinkByAdmin(Long socialId) { + webClient.post() + .uri(unlinkUri) + .header(HttpHeaders.AUTHORIZATION, "KakaoAK " + adminKey) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(BodyInserters.fromFormData("target_id_type", "user_id") + .with("target_id", String.valueOf(socialId))) + .retrieve() + .toBodilessEntity() + .block() + ; + + log.info("[KAKAO] unlink OK. kakaoUserId={}", socialId); + } +} diff --git a/src/main/java/umc/cockple/demo/global/oauth2/service/KakaoOauthService.java b/src/main/java/umc/cockple/demo/global/oauth2/service/KakaoOauthService.java index f3595a258..56c3148f5 100644 --- a/src/main/java/umc/cockple/demo/global/oauth2/service/KakaoOauthService.java +++ b/src/main/java/umc/cockple/demo/global/oauth2/service/KakaoOauthService.java @@ -1,6 +1,7 @@ package umc.cockple.demo.global.oauth2.service; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import umc.cockple.demo.domain.member.domain.Member; @@ -10,6 +11,7 @@ import umc.cockple.demo.domain.member.exception.MemberException; import umc.cockple.demo.domain.member.repository.MemberRepository; import umc.cockple.demo.global.auth.RefreshTokenRepository; +import umc.cockple.demo.global.auth.TokenVersionRepository; import umc.cockple.demo.global.jwt.domain.JwtTokenProvider; import umc.cockple.demo.global.jwt.domain.TokenRefreshResponse; import umc.cockple.demo.global.oauth2.domain.KakaoClient; @@ -21,12 +23,14 @@ @Service @RequiredArgsConstructor +@Slf4j public class KakaoOauthService { private final KakaoClient kakaoClient; private final MemberRepository memberRepository; private final JwtTokenProvider jwtTokenProvider; private final RefreshTokenRepository refreshTokenRepository; + private final TokenVersionRepository tokenVersionRepository; @Transactional public KakaoLoginResponseDTO signup(String code) { @@ -53,9 +57,10 @@ public KakaoLoginResponseDTO signup(String code) { newMember = true; } - // 4. jwt 발급 - String accessToken = jwtTokenProvider.createAccessToken(member.getId(), member.getNickname()); - String refreshToken = jwtTokenProvider.createRefreshToken(member.getId(), member.getNickname()); + // 4. jwt 발급 (현재 tokenVersion 주입) + long tokenVersion = tokenVersionRepository.getVersion(member.getId()); + String accessToken = jwtTokenProvider.createAccessToken(member.getId(), member.getNickname(), tokenVersion); + String refreshToken = jwtTokenProvider.createRefreshToken(member.getId(), member.getNickname(), tokenVersion); // 5. refresh는 redis에 저장 refreshTokenRepository.save(refreshToken, member.getId()); @@ -84,10 +89,11 @@ public KakaoLoginResponseDTO createDevToken() { .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); // accessToken: 2주 만료 - String accessToken = jwtTokenProvider.createDevToken(member.getId(), member.getNickname()); + long tokenVersion = tokenVersionRepository.getVersion(member.getId()); + String accessToken = jwtTokenProvider.createDevToken(member.getId(), member.getNickname(), tokenVersion); // refreshToken: 기본 만료 - String refreshToken = jwtTokenProvider.createRefreshToken(member.getId(), member.getNickname()); + String refreshToken = jwtTokenProvider.createRefreshToken(member.getId(), member.getNickname(), tokenVersion); // refreshToken Redis에 저장 refreshTokenRepository.save(refreshToken, member.getId()); @@ -109,10 +115,11 @@ public KakaoLoginResponseDTO createOtherDevToken() { .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); // accessToken: 2주 만료 - String accessToken = jwtTokenProvider.createDevToken(member.getId(), member.getNickname()); + long tokenVersion = tokenVersionRepository.getVersion(member.getId()); + String accessToken = jwtTokenProvider.createDevToken(member.getId(), member.getNickname(), tokenVersion); // refreshToken: 기본 만료 - String refreshToken = jwtTokenProvider.createRefreshToken(member.getId(), member.getNickname()); + String refreshToken = jwtTokenProvider.createRefreshToken(member.getId(), member.getNickname(), tokenVersion); // refreshToken Redis에 저장 refreshTokenRepository.save(refreshToken, member.getId()); @@ -129,9 +136,15 @@ public KakaoLoginResponseDTO createOtherDevToken() { } public TokenRefreshResponse validateMember(String refreshToken) { - // Redis에서 memberId 조회 및 삭제 (GETDEL - 원자적 처리로 동시 요청 시 중복 발급 방지) - Long memberId = refreshTokenRepository.findAndDeleteByToken(refreshToken) - .orElseThrow(() -> new MemberException(MemberErrorCode.INVALID_REFRESH_TOKEN)); + Optional memberIdOpt = refreshTokenRepository.consumeAndMark(refreshToken); + + // 활성 저장소에 없는 경우 - 재사용(탈취) 여부를 판별해 대응한 뒤 거부 + if (memberIdOpt.isEmpty()) { + detectAndHandleReuse(refreshToken); + throw new MemberException(MemberErrorCode.INVALID_REFRESH_TOKEN); + } + + Long memberId = memberIdOpt.get(); Member member = memberRepository.findById(memberId) .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); @@ -141,13 +154,37 @@ public TokenRefreshResponse validateMember(String refreshToken) { throw new MemberException(MemberErrorCode.INVALID_REFRESH_TOKEN); } - // 액세스 토큰 재발급 - String newAccessToken = jwtTokenProvider.createAccessToken(member.getId(), member.getNickname()); + // 토큰 버전 검증 - 강제 무효화(탈퇴/탈취 대응 등)된 리프레시 토큰 차단 + long tokenVersion = tokenVersionRepository.getVersion(member.getId()); + if (jwtTokenProvider.getTokenVersion(refreshToken) != tokenVersion) { + throw new MemberException(MemberErrorCode.INVALID_REFRESH_TOKEN); + } + + // 액세스 토큰 재발급 (현재 tokenVersion 주입) + String newAccessToken = jwtTokenProvider.createAccessToken(member.getId(), member.getNickname(), tokenVersion); // 새 리프레시 토큰 발급 및 Redis 저장 - String newRefreshToken = jwtTokenProvider.createRefreshToken(member.getId(), member.getNickname()); + String newRefreshToken = jwtTokenProvider.createRefreshToken(member.getId(), member.getNickname(), tokenVersion); refreshTokenRepository.save(newRefreshToken, member.getId()); return new TokenRefreshResponse(newAccessToken, newRefreshToken); } + + private void detectAndHandleReuse(String refreshToken) { + if (!jwtTokenProvider.validateToken(refreshToken)) { + return; + } + // refresh 용도가 아닌 토큰(access를 재발급에 오용)은 탈취가 아니므로 무효화하지 않고 단순 거부 + if (!jwtTokenProvider.isRefreshToken(refreshToken)) { + return; + } + // grace window 이내 정상 소비 이력 존재 → 동시 재발급 경쟁/재시도로 판단, 무효화 x + if (refreshTokenRepository.isRecentlyConsumed(refreshToken)) { + return; + } + // 재사용(탈취) 확정 + Long memberId = jwtTokenProvider.getUserId(refreshToken); + long newVersion = tokenVersionRepository.increment(memberId); + log.warn("리프레시 토큰 재사용 감지 - 회원 {} 의 모든 토큰을 무효화합니다. (tokenVersion={})", memberId, newVersion); + } } diff --git a/src/main/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilter.java b/src/main/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilter.java index 6fb22a186..c5b7faf79 100644 --- a/src/main/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilter.java +++ b/src/main/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilter.java @@ -1,89 +1,86 @@ -package umc.cockple.demo.global.security.filter; - -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.authentication.BadCredentialsException; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Component; -import org.springframework.web.filter.OncePerRequestFilter; -import umc.cockple.demo.domain.member.domain.Member; -import umc.cockple.demo.domain.member.enums.MemberStatus; -import umc.cockple.demo.domain.member.exception.MemberErrorCode; -import umc.cockple.demo.domain.member.exception.MemberException; -import umc.cockple.demo.domain.member.repository.MemberRepository; -import umc.cockple.demo.global.exception.RestAuthenticationEntryPoint; -import umc.cockple.demo.global.jwt.domain.JwtTokenProvider; - -import java.io.IOException; - -@RequiredArgsConstructor -@Component -@Slf4j -public class JwtAuthenticationFilter extends OncePerRequestFilter { - - private final JwtTokenProvider jwtTokenProvider; - private final MemberRepository memberRepository; - private final RestAuthenticationEntryPoint restEntryPoint; - - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - - String token = resolveToken(request); - - // token이 null -> 로그 찍고 그대로 진행 - if (token == null) { - log.trace("Authorization 헤더에 토큰 없음"); - filterChain.doFilter(request, response); - return; - } - - try { - - if (!jwtTokenProvider.validateToken(token)) { - throw new MemberException(MemberErrorCode.INVALID_TOKEN); - } - - - Long memberId = jwtTokenProvider.getUserId(token); - Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); - - // 탈퇴 회원 검증 - if (member.getIsActive() == MemberStatus.INACTIVE) { - throw new MemberException(MemberErrorCode.ALREADY_WITHDRAW); - } - - Authentication auth = jwtTokenProvider.getAuthentication(token); - SecurityContextHolder.getContext().setAuthentication(auth); - log.debug("인증 정보 SecurityContext에 저장 완료: {}", auth.getName()); - - filterChain.doFilter(request, response); - - } catch (MemberException e) { - SecurityContextHolder.clearContext(); - restEntryPoint.commence(request, response, new BadCredentialsException(e.getMessage() == null ? "UNAUTHORIZED" : e.getMessage())); - - } catch (RuntimeException e) { // 혹시 남아있는 경우에도 401로 변환 - SecurityContextHolder.clearContext(); - restEntryPoint.commence(request, response, new BadCredentialsException(e.getMessage() == null ? "UNAUTHORIZED" : e.getMessage())); - } - - } - - - - private String resolveToken(HttpServletRequest request) { - String bearerToken = request.getHeader("Authorization"); - if (bearerToken != null && bearerToken.startsWith("Bearer ")) { - return bearerToken.substring(7); - } - - return null; - } - -} +package umc.cockple.demo.global.security.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import umc.cockple.demo.domain.member.exception.MemberErrorCode; +import umc.cockple.demo.domain.member.exception.MemberException; +import umc.cockple.demo.global.exception.RestAuthenticationEntryPoint; +import umc.cockple.demo.global.jwt.domain.JwtTokenProvider; + +import java.io.IOException; + +@RequiredArgsConstructor +@Component +@Slf4j +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + public static final String MEMBER_ID = "memberId"; + + private final JwtTokenProvider jwtTokenProvider; + private final RestAuthenticationEntryPoint restEntryPoint; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + + MDC.remove(MEMBER_ID); + try { + String token = resolveToken(request); + // token이 null -> 로그 찍고 그대로 진행 + if (token == null) { + log.trace("Authorization 헤더에 토큰 없음"); + filterChain.doFilter(request, response); + return; + } + + try { + if (!jwtTokenProvider.validateToken(token)) { + throw new MemberException(MemberErrorCode.INVALID_TOKEN); + } + + // refresh 토큰을 일반 API 인증에 사용하는 것을 차단 + if (!jwtTokenProvider.isAccessToken(token)) { + throw new MemberException(MemberErrorCode.INVALID_TOKEN); + } + + Long memberId = jwtTokenProvider.getUserId(token); + MDC.put(MEMBER_ID, String.valueOf(memberId)); + + Authentication auth = jwtTokenProvider.getAuthentication(token); + SecurityContextHolder.getContext().setAuthentication(auth); + log.debug("인증 정보 SecurityContext에 저장 완료: {}", auth.getName()); + + filterChain.doFilter(request, response); + + } catch (MemberException e) { + SecurityContextHolder.clearContext(); + restEntryPoint.commence(request, response, new BadCredentialsException(e.getMessage() == null ? "UNAUTHORIZED" : e.getMessage())); + + } catch (RuntimeException e) { // 혹시 남아있는 경우에도 401로 변환 + SecurityContextHolder.clearContext(); + restEntryPoint.commence(request, response, new BadCredentialsException(e.getMessage() == null ? "UNAUTHORIZED" : e.getMessage())); + } + } finally { + MDC.remove(MEMBER_ID); + } + + } + + private String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (bearerToken != null && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + + return null; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 258ef9061..fccd0b611 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -71,6 +71,12 @@ spring: gcs: bucket: ${GCS_BUCKET} +cockple: + web: + # WS도 이 목록 공유. cockple-fe.vercel.app/(끝슬래시)는 WS 기존값 유지용(무해). + allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:5173,https://cockple.store,https://www.cockple.store,https://staging.cockple.store,https://cockple-fe.vercel.app,https://cockple-fe.vercel.app/} + cookie-domain: ${COOKIE_DOMAIN:.cockple.store} + kakao: client-id: ${KAKAO_CLIENT_ID} client-secret: ${KAKAO_CLIENT_SECRET} @@ -84,6 +90,48 @@ jwt: secret: ${JWT_SECRET_KEY} access-token-validity: 900000 refresh-token-validity: 1209600000 + refresh-token-reuse-grace: 30000 + +async: + # 실시간 채팅 풀 (저지연 내부 I/O) + chat: + core-size: 8 + max-size: 16 + queue-capacity: 500 + await-termination-seconds: 30 + # FCM 알림 풀 + notification: + core-size: 4 + max-size: 12 + queue-capacity: 1000 + await-termination-seconds: 30 + +fcm: + fake-latency-ms: 0 + +# 관측성(Observability) — OTLP로 메트릭 push +# 기본 off, staging(및 추후 prod)에서 env로만 켠다. +management: + otlp: + metrics: + export: + enabled: ${OTLP_METRICS_ENABLED:false} + url: ${OTEL_URL:http://localhost:4318}/v1/metrics + step: ${OTLP_STEP:1m} + headers: + Authorization: ${OTEL_AUTH:} + opentelemetry: + resource-attributes: + service.name: ${spring.application.name} + deployment.environment: ${SPRING_PROFILES_ACTIVE:local} + metrics: + distribution: + percentiles-histogram: + http.server.requests: true + endpoints: + web: + exposure: + include: health # 웹 노출 최소화 (메트릭은 OTLP push라 endpoint 불필요) logging: level: diff --git a/src/main/resources/db/migration/V2026.06.13.16.40__add_unread_status_indexes.sql b/src/main/resources/db/migration/V2026.06.13.16.40__add_unread_status_indexes.sql new file mode 100644 index 000000000..604d76e62 --- /dev/null +++ b/src/main/resources/db/migration/V2026.06.13.16.40__add_unread_status_indexes.sql @@ -0,0 +1,5 @@ +CREATE INDEX idx_message_read_member_read_room_message + ON message_read_status (member_id, is_read, chat_room_id, chat_message_id); + +CREATE INDEX idx_chat_room_member_member_room_status_last_read + ON chat_room_member (member_id, chat_room_id, status, last_read_message_id); diff --git a/src/main/resources/db/migration/V2026.06.27.00.00__add_unique_profile_img_member.sql b/src/main/resources/db/migration/V2026.06.27.00.00__add_unique_profile_img_member.sql new file mode 100644 index 000000000..472d25515 --- /dev/null +++ b/src/main/resources/db/migration/V2026.06.27.00.00__add_unique_profile_img_member.sql @@ -0,0 +1,36 @@ +-- 삭제될 중복 row 가 가리키던 이미지 중 '고아가 되는 key'(살아남는 row 가 안 쓰는 key)를 +-- object_storage_delete_outbox 에 적재한다. 기존 스케줄러가 GCS 에서 비동기로 삭제한다. +-- 반드시 아래 DELETE 보다 먼저 실행되어야 한다(삭제 후엔 img_key 를 알 수 없음). +INSERT INTO object_storage_delete_outbox + (object_key, source_type, source_id, status, retry_count, created_at, updated_at) +SELECT DISTINCT doomed.img_key, 'MEMBER_PROFILE_IMG', doomed.member_id, 'PENDING', 0, NOW(6), NOW(6) +FROM profile_img doomed +JOIN (SELECT member_id, MAX(id) AS keep_id + FROM profile_img + WHERE member_id IS NOT NULL + GROUP BY member_id) keep + ON doomed.member_id = keep.member_id +WHERE doomed.id <> keep.keep_id + AND doomed.img_key NOT IN ( + SELECT survivor.img_key + FROM profile_img survivor + WHERE survivor.id IN (SELECT MAX(id) + FROM profile_img + WHERE member_id IS NOT NULL + GROUP BY member_id)); + +-- member 당 1개만 남기고 중복 row 정리 +DELETE pi +FROM profile_img pi +JOIN ( + SELECT member_id, MAX(id) AS keep_id + FROM profile_img + WHERE member_id IS NOT NULL + GROUP BY member_id +) keep + ON pi.member_id = keep.member_id +WHERE pi.id <> keep.keep_id; + +-- member 당 profile_img 1개를 DB 레벨에서 보장한다. +ALTER TABLE profile_img + ADD CONSTRAINT uq_profile_img_member UNIQUE (member_id); diff --git a/src/main/resources/db/migration/V2026.06.27.00.10__add_profile_img_version.sql b/src/main/resources/db/migration/V2026.06.27.00.10__add_profile_img_version.sql new file mode 100644 index 000000000..cfe91c0fb --- /dev/null +++ b/src/main/resources/db/migration/V2026.06.27.00.10__add_profile_img_version.sql @@ -0,0 +1,4 @@ +-- 프로필 사진 교체(UPDATE) 동시성에서 lost update 를 막기 위한 낙관적 락 버전 컬럼. +-- 기존 row 는 0 으로 시작한다. +ALTER TABLE profile_img + ADD COLUMN version BIGINT NOT NULL DEFAULT 0; diff --git a/src/main/resources/db/migration/V2026.07.06.00.00__add_member_token_version.sql b/src/main/resources/db/migration/V2026.07.06.00.00__add_member_token_version.sql new file mode 100644 index 000000000..05fae20be --- /dev/null +++ b/src/main/resources/db/migration/V2026.07.06.00.00__add_member_token_version.sql @@ -0,0 +1 @@ +ALTER TABLE member ADD COLUMN token_version BIGINT NOT NULL DEFAULT 0; diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 000000000..3d6584bbf --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,139 @@ + + + + + + + + + + + + + + + ${CONSOLE_LOG_PATTERN} + UTF-8 + + + + + + + + ${FILE_LOG_PATTERN} + UTF-8 + + + + + + ${APP_LOG_FILE} + + ${FILE_LOG_PATTERN} + UTF-8 + + + ${LOG_PATH}/application.%d{yyyy-MM-dd}.%i.log.gz + 100MB + 30 + 3GB + + + + + ${WEBSOCKET_LOG_FILE} + + ${FILE_LOG_PATTERN} + UTF-8 + + + ${LOG_PATH}/websocket.%d{yyyy-MM-dd}.%i.log.gz + 50MB + 14 + 1GB + + + + + ${ERROR_LOG_FILE} + + ERROR + + + ${FILE_LOG_PATTERN} + UTF-8 + + + ${LOG_PATH}/error.%d{yyyy-MM-dd}.%i.log.gz + 50MB + 30 + 1GB + + + + + 0 + 1024 + true + false + + + + + 0 + 2048 + true + false + + + + + + ERROR + + 0 + 512 + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/test/java/umc/cockple/demo/domain/bookmark/integration/BookmarkQueryCountTest.java b/src/test/java/umc/cockple/demo/domain/bookmark/integration/BookmarkQueryCountTest.java new file mode 100644 index 000000000..f32505cba --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/bookmark/integration/BookmarkQueryCountTest.java @@ -0,0 +1,136 @@ +package umc.cockple.demo.domain.bookmark.integration; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +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 umc.cockple.demo.domain.bookmark.domain.ExerciseBookmark; +import umc.cockple.demo.domain.bookmark.domain.PartyBookmark; +import umc.cockple.demo.domain.bookmark.enums.BookmarkedExerciseOrderType; +import umc.cockple.demo.domain.bookmark.repository.ExerciseBookmarkRepository; +import umc.cockple.demo.domain.bookmark.repository.PartyBookmarkRepository; +import umc.cockple.demo.domain.bookmark.service.BookmarkQueryService; +import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.repository.MemberExerciseRepository; +import umc.cockple.demo.domain.member.repository.MemberPartyRepository; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.domain.party.domain.Party; +import umc.cockple.demo.domain.party.domain.PartyAddr; +import umc.cockple.demo.domain.party.enums.PartyOrderType; +import umc.cockple.demo.domain.party.repository.PartyAddrRepository; +import umc.cockple.demo.domain.party.repository.PartyRepository; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.global.enums.Role; +import umc.cockple.demo.support.IntegrationTestBase; +import umc.cockple.demo.support.fixture.ExerciseFixture; +import umc.cockple.demo.support.fixture.MemberFixture; +import umc.cockple.demo.support.fixture.PartyFixture; + +import java.time.LocalDate; + +import static umc.cockple.demo.support.QueryCountAssert.assertQueryCount; + +// 찜 목록 조회 N+1 회귀 방지 테스트 +@DisplayName("북마크 찜 목록 N+1 회귀 테스트") +class BookmarkQueryCountTest extends IntegrationTestBase { + + /** + * 찜한 운동 목록 조회의 기대 쿼리 수 + * 1) 회원 조회 2) 찜+운동+party+exerciseAddr+partyImg+chatRoom+levels fetch join + * 3) 가입 모임 IN 4) 참여 운동 IN 5) 참여 인원 수 공통 count + */ + private static final int EXERCISE_BOOKMARK_QUERY_COUNT = 5; + + /** + * 찜한 모임 목록 조회의 기대 쿼리 수 + * 1) 회원 조회 2) 찜+party+partyAddr+partyImg+chatRoom+exercises fetch join 3) levels 배치 로딩 + */ + private static final int PARTY_BOOKMARK_QUERY_COUNT = 3; + + @Autowired BookmarkQueryService bookmarkQueryService; + @Autowired MemberRepository memberRepository; + @Autowired PartyRepository partyRepository; + @Autowired PartyAddrRepository partyAddrRepository; + @Autowired ExerciseRepository exerciseRepository; + @Autowired ExerciseBookmarkRepository exerciseBookmarkRepository; + @Autowired PartyBookmarkRepository partyBookmarkRepository; + @Autowired MemberPartyRepository memberPartyRepository; + @Autowired MemberExerciseRepository memberExerciseRepository; + + @PersistenceContext EntityManager em; + + @AfterEach + void tearDown() { + exerciseBookmarkRepository.deleteAll(); + partyBookmarkRepository.deleteAll(); + memberExerciseRepository.deleteAll(); + exerciseRepository.deleteAll(); + memberPartyRepository.deleteAll(); + partyRepository.deleteAll(); + partyAddrRepository.deleteAll(); + memberRepository.deleteAll(); + } + + @Test + @DisplayName("찜한 운동 목록 - 찜 개수와 무관하게 고정된 쿼리 수만 실행한다") + void getAllExerciseBookmarks_hasNoNPlusOne() { + Member small = createMember(7001L); + Member large = createMember(7002L); + for (int i = 0; i < 2; i++) seedExerciseBookmark(small, i); + for (int i = 0; i < 8; i++) seedExerciseBookmark(large, i); + + assertQueryCount(em, EXERCISE_BOOKMARK_QUERY_COUNT, () -> + bookmarkQueryService.getAllExerciseBookmarks(small.getId(), BookmarkedExerciseOrderType.LATEST)); + assertQueryCount(em, EXERCISE_BOOKMARK_QUERY_COUNT, () -> + bookmarkQueryService.getAllExerciseBookmarks(large.getId(), BookmarkedExerciseOrderType.LATEST)); + } + + @Test + @DisplayName("찜한 모임 목록 - 찜 개수와 무관하게 고정된 쿼리 수만 실행한다") + void getAllPartyBookmarks_hasNoNPlusOne() { + Member small = createMember(7003L); + Member large = createMember(7004L); + for (int i = 0; i < 2; i++) seedPartyBookmark(small, i); + for (int i = 0; i < 8; i++) seedPartyBookmark(large, i); + + assertQueryCount(em, PARTY_BOOKMARK_QUERY_COUNT, () -> + bookmarkQueryService.getAllPartyBookmarks(small.getId(), PartyOrderType.LATEST)); + assertQueryCount(em, PARTY_BOOKMARK_QUERY_COUNT, () -> + bookmarkQueryService.getAllPartyBookmarks(large.getId(), PartyOrderType.LATEST)); + } + + // === 시딩 === + + private Member createMember(long socialId) { + return memberRepository.save( + MemberFixture.createMember("회원" + socialId, Gender.MALE, Level.A, socialId)); + } + + private void seedExerciseBookmark(Member member, int idx) { + Party party = newParty(member, "운동모임" + member.getId() + "_" + idx); + memberPartyRepository.save(MemberFixture.createMemberParty(party, member, Role.PARTY_MANAGER)); + var exercise = exerciseRepository.save( + ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().plusDays(idx + 1))); + memberExerciseRepository.save(MemberFixture.createMemberExercise(member, exercise)); + exerciseBookmarkRepository.save(ExerciseBookmark.builder() + .member(member).exercise(exercise).build()); + } + + private void seedPartyBookmark(Member member, int idx) { + Party party = newParty(member, "찜모임" + member.getId() + "_" + idx); + exerciseRepository.save( + ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().plusDays(idx + 1))); + partyBookmarkRepository.save(PartyBookmark.builder() + .party(party).member(member).orderType(PartyOrderType.LATEST).build()); + } + + private Party newParty(Member member, String name) { + // PartyAddr는 (addr1, addr2) UNIQUE 제약이 있어 addr2를 유니크한 name으로 만든다. + PartyAddr addr = partyAddrRepository.save(PartyFixture.createPartyAddr("경기도", name)); + return partyRepository.save(PartyFixture.createParty(name, member.getId(), addr)); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/bookmark/service/BookmarkQueryServiceTest.java b/src/test/java/umc/cockple/demo/domain/bookmark/service/BookmarkQueryServiceTest.java index 75602159e..b3e6ef17f 100644 --- a/src/test/java/umc/cockple/demo/domain/bookmark/service/BookmarkQueryServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/bookmark/service/BookmarkQueryServiceTest.java @@ -18,6 +18,7 @@ import umc.cockple.demo.domain.bookmark.repository.ExerciseBookmarkRepository; import umc.cockple.demo.domain.bookmark.repository.PartyBookmarkRepository; import umc.cockple.demo.domain.exercise.domain.Exercise; +import umc.cockple.demo.domain.exercise.service.query.lookup.ExerciseParticipantCountLookupService; import umc.cockple.demo.domain.file.service.FileService; import umc.cockple.demo.domain.member.domain.Member; import umc.cockple.demo.domain.member.exception.MemberErrorCode; @@ -39,11 +40,13 @@ import java.time.LocalTime; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; @@ -62,6 +65,7 @@ class BookmarkQueryServiceTest { @Mock private PartyBookmarkRepository partyBookmarkRepository; @Mock private MemberPartyRepository memberPartyRepository; @Mock private MemberExerciseRepository memberExerciseRepository; + @Mock private ExerciseParticipantCountLookupService exerciseParticipantCountLookupService; @Mock private MemberRepository memberRepository; @Mock private BookmarkConverter bookmarkConverter; @@ -103,7 +107,7 @@ class Success { void noBookmarks_returnsEmptyList() { // given given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); - given(exerciseBookmarkRepository.findAllByMember(member)).willReturn(new ArrayList<>()); + given(exerciseBookmarkRepository.findAllByMemberWithDetails(member)).willReturn(new ArrayList<>()); given(memberPartyRepository.findAllPartyIdsByMemberAndPartyIds(anyLong(), anyList())) .willReturn(new ArrayList<>()); given(memberExerciseRepository.findAllExerciseIdsByMemberAndExerciseIds(anyLong(), anyList())) @@ -138,14 +142,16 @@ void latestOrder_returnsNewestFirst() { .exerciseId(102L).partyName("테스트 모임").build(); given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); - given(exerciseBookmarkRepository.findAllByMember(member)).willReturn(bookmarks); + given(exerciseBookmarkRepository.findAllByMemberWithDetails(member)).willReturn(bookmarks); given(memberPartyRepository.findAllPartyIdsByMemberAndPartyIds(anyLong(), anyList())) .willReturn(List.of(party.getId())); given(memberExerciseRepository.findAllExerciseIdsByMemberAndExerciseIds(anyLong(), anyList())) .willReturn(List.of(oldExercise.getId(), newExercise.getId())); - given(bookmarkConverter.exerciseBookmarkToDTO(eq(bookmarkNew), any(Boolean.class), any(Boolean.class))) + given(exerciseParticipantCountLookupService.getParticipantCountsByExerciseIds(anyList())) + .willReturn(Map.of(newExercise.getId(), 2, oldExercise.getId(), 1)); + given(bookmarkConverter.exerciseBookmarkToDTO(eq(bookmarkNew), any(Boolean.class), any(Boolean.class), anyInt())) .willReturn(dtoNew); - given(bookmarkConverter.exerciseBookmarkToDTO(eq(bookmarkOld), any(Boolean.class), any(Boolean.class))) + given(bookmarkConverter.exerciseBookmarkToDTO(eq(bookmarkOld), any(Boolean.class), any(Boolean.class), anyInt())) .willReturn(dtoOld); // when @@ -179,14 +185,16 @@ void earliestOrder_returnsOldestFirst() { .exerciseId(102L).partyName("테스트 모임").build(); given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); - given(exerciseBookmarkRepository.findAllByMember(member)).willReturn(bookmarks); + given(exerciseBookmarkRepository.findAllByMemberWithDetails(member)).willReturn(bookmarks); given(memberPartyRepository.findAllPartyIdsByMemberAndPartyIds(anyLong(), anyList())) .willReturn(List.of(party.getId())); given(memberExerciseRepository.findAllExerciseIdsByMemberAndExerciseIds(anyLong(), anyList())) .willReturn(List.of(oldExercise.getId(), newExercise.getId())); - given(bookmarkConverter.exerciseBookmarkToDTO(eq(bookmarkOld), any(Boolean.class), any(Boolean.class))) + given(exerciseParticipantCountLookupService.getParticipantCountsByExerciseIds(anyList())) + .willReturn(Map.of(oldExercise.getId(), 1, newExercise.getId(), 2)); + given(bookmarkConverter.exerciseBookmarkToDTO(eq(bookmarkOld), any(Boolean.class), any(Boolean.class), anyInt())) .willReturn(dtoOld); - given(bookmarkConverter.exerciseBookmarkToDTO(eq(bookmarkNew), any(Boolean.class), any(Boolean.class))) + given(bookmarkConverter.exerciseBookmarkToDTO(eq(bookmarkNew), any(Boolean.class), any(Boolean.class), anyInt())) .willReturn(dtoNew); // when @@ -214,12 +222,14 @@ void convertsBookmarkWithCorrectIncludeFlags() { .exerciseId(101L).includeParty(true).includeExercise(false).build(); given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); - given(exerciseBookmarkRepository.findAllByMember(member)).willReturn(new ArrayList<>(List.of(bookmark))); + given(exerciseBookmarkRepository.findAllByMemberWithDetails(member)).willReturn(new ArrayList<>(List.of(bookmark))); given(memberPartyRepository.findAllPartyIdsByMemberAndPartyIds(eq(member.getId()), anyList())) .willReturn(List.of(party.getId())); // 모임 멤버 given(memberExerciseRepository.findAllExerciseIdsByMemberAndExerciseIds(eq(member.getId()), anyList())) .willReturn(new ArrayList<>()); // 운동 미참여 - given(bookmarkConverter.exerciseBookmarkToDTO(bookmark, true, false)).willReturn(dto); + given(exerciseParticipantCountLookupService.getParticipantCountsByExerciseIds(anyList())) + .willReturn(Map.of(exercise.getId(), 3)); + given(bookmarkConverter.exerciseBookmarkToDTO(eq(bookmark), eq(true), eq(false), eq(3))).willReturn(dto); // when List result = diff --git a/src/test/java/umc/cockple/demo/domain/bookmark/service/ExerciseBookmarkLookupServiceTest.java b/src/test/java/umc/cockple/demo/domain/bookmark/service/ExerciseBookmarkLookupServiceTest.java new file mode 100644 index 000000000..2f91bcc38 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/bookmark/service/ExerciseBookmarkLookupServiceTest.java @@ -0,0 +1,67 @@ +package umc.cockple.demo.domain.bookmark.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import umc.cockple.demo.domain.bookmark.repository.ExerciseBookmarkRepository; +import umc.cockple.demo.domain.bookmark.service.query.lookup.ExerciseBookmarkLookupService; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ExerciseBookmarkLookupService") +class ExerciseBookmarkLookupServiceTest { + + @InjectMocks + private ExerciseBookmarkLookupService exerciseBookmarkLookupService; + + @Mock + private ExerciseBookmarkRepository exerciseBookmarkRepository; + + @Nested + @DisplayName("getBookmarkStatus - 운동 ID 목록 기준 북마크 여부 조회") + class GetBookmarkStatus { + + @Test + @DisplayName("운동 ID 목록이 비어있으면 빈 Map을 반환하고 Repository를 호출하지 않는다") + void emptyExerciseIds_returnsEmptyMapWithoutRepositoryCall() { + // when + Map result = exerciseBookmarkLookupService.getBookmarkStatus(1L, List.of()); + + // then + assertThat(result).isEmpty(); + verifyNoInteractions(exerciseBookmarkRepository); + } + + @Test + @DisplayName("요청한 운동 ID 전체에 대해 북마크 여부 Map을 반환한다") + void repositoryBookmarkIds_returnsBookmarkStatusMap() { + // given + Long memberId = 1L; + List exerciseIds = List.of(10L, 20L, 30L); + given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds(memberId, exerciseIds)) + .willReturn(List.of(10L, 30L)); + + // when + Map result = exerciseBookmarkLookupService.getBookmarkStatus(memberId, exerciseIds); + + // then + assertThat(result).containsExactlyInAnyOrderEntriesOf(Map.of( + 10L, true, + 20L, false, + 30L, true + )); + verify(exerciseBookmarkRepository).findAllExerciseIdsByMemberIdAndExerciseIds(memberId, exerciseIds); + } + } +} diff --git a/src/test/java/umc/cockple/demo/domain/chat/converter/ChatConverterTest.java b/src/test/java/umc/cockple/demo/domain/chat/converter/ChatWebSocketResponseAssemblerTest.java similarity index 90% rename from src/test/java/umc/cockple/demo/domain/chat/converter/ChatConverterTest.java rename to src/test/java/umc/cockple/demo/domain/chat/converter/ChatWebSocketResponseAssemblerTest.java index 5023a394e..d962ae0e4 100644 --- a/src/test/java/umc/cockple/demo/domain/chat/converter/ChatConverterTest.java +++ b/src/test/java/umc/cockple/demo/domain/chat/converter/ChatWebSocketResponseAssemblerTest.java @@ -20,10 +20,10 @@ import static org.assertj.core.api.Assertions.assertThat; -@DisplayName("ChatConverter") -class ChatConverterTest { +@DisplayName("ChatWebSocketResponseAssembler") +class ChatWebSocketResponseAssemblerTest { - private final ChatConverter chatConverter = new ChatConverter(); + private final ChatWebSocketResponseAssembler assembler = new ChatWebSocketResponseAssembler(); @Test @DisplayName("일반 WebSocket 메시지 응답에는 TEXT messageType이 포함된다") @@ -40,7 +40,7 @@ void toSendMessageResponse_containsTextMessageType() { ReflectionTestUtils.setField(message, "id", 100L); ReflectionTestUtils.setField(message, "createdAt", LocalDateTime.of(2026, 5, 21, 23, 0)); - WebSocketMessageDTO.MessageResponse response = chatConverter.toSendMessageResponse( + WebSocketMessageDTO.MessageResponse response = assembler.toSendMessageResponse( chatRoom.getId(), "안녕하세요", List.of(ChatCommonDTO.FileInfo.builder().imageId(1L).imageUrl("https://cdn.example.com/1").build()), @@ -68,7 +68,7 @@ void toSystemMessageResponse_containsSystemMessageType() { ReflectionTestUtils.setField(systemMessage, "id", 200L); ReflectionTestUtils.setField(systemMessage, "createdAt", LocalDateTime.of(2026, 5, 21, 23, 5)); - WebSocketMessageDTO.MessageResponse response = chatConverter.toSystemMessageResponse( + WebSocketMessageDTO.MessageResponse response = assembler.toSystemMessageResponse( chatRoom.getId(), systemMessage.getContent(), systemMessage diff --git a/src/test/java/umc/cockple/demo/domain/chat/integration/ChatIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/chat/integration/ChatIntegrationTest.java index 53d8bb507..b13b5d6cc 100644 --- a/src/test/java/umc/cockple/demo/domain/chat/integration/ChatIntegrationTest.java +++ b/src/test/java/umc/cockple/demo/domain/chat/integration/ChatIntegrationTest.java @@ -149,6 +149,49 @@ void findPreviousMessages_returnsSystemMessageWithoutSender() { } } + @Nested + @DisplayName("GET /api/chats/unread-status - 채팅 안 읽은 메시지 여부 조회") + class GetUnreadStatus { + + @Test + @DisplayName("200 - 안 읽은 메시지가 없으면 모든 여부를 false로 반환한다") + void getUnreadStatus_noUnreadMessages() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(get("/api/chats/unread-status")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.hasUnread").value(false)) + .andExpect(jsonPath("$.data.hasPartyUnread").value(false)) + .andExpect(jsonPath("$.data.hasDirectUnread").value(false)); + } + + @Test + @DisplayName("200 - 모임과 JOINED 개인 채팅 안읽음 여부를 한 번에 반환한다") + void getUnreadStatus_returnsPartyAndDirectUnreadStatus() throws Exception { + createUnreadStatusScenario(); + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(get("/api/chats/unread-status")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.hasUnread").value(true)) + .andExpect(jsonPath("$.data.hasPartyUnread").value(true)) + .andExpect(jsonPath("$.data.hasDirectUnread").value(true)); + } + + @Test + @DisplayName("200 - 타 회원, PENDING 개인 채팅, 마지막 읽음 이전 메시지는 제외한다") + void getUnreadStatus_excludesInvisibleUnreadRows() throws Exception { + createExcludedUnreadStatusScenario(); + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(get("/api/chats/unread-status")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.hasUnread").value(false)) + .andExpect(jsonPath("$.data.hasPartyUnread").value(false)) + .andExpect(jsonPath("$.data.hasDirectUnread").value(false)); + } + } + @Nested @DisplayName("GET /api/chats/parties - 모임 채팅방 목록 조회") class GetPartyChatRooms { @@ -1444,4 +1487,63 @@ void fail_invalidToken() throws Exception { } } } + + private void createUnreadStatusScenario() { + ChatRoomMember partyMembership = chatRoomMemberRepository.save( + ChatRoomMember.create(partyChatRoom, member)); + chatRoomMemberRepository.save(ChatRoomMember.create(partyChatRoom, otherMember)); + + ChatMessage alreadyReadPartyMessage = chatMessageRepository.save( + ChatFixture.createTextMessage(partyChatRoom, otherMember, "이미 읽은 모임 메시지")); + ChatMessage firstUnreadPartyMessage = chatMessageRepository.save( + ChatFixture.createTextMessage(partyChatRoom, otherMember, "읽지 않은 모임 메시지 1")); + ChatMessage secondUnreadPartyMessage = chatMessageRepository.save( + ChatFixture.createTextMessage(partyChatRoom, otherMember, "읽지 않은 모임 메시지 2")); + + partyMembership.updateLastReadMessageId(alreadyReadPartyMessage.getId()); + chatRoomMemberRepository.saveAndFlush(partyMembership); + + chatRoomMemberRepository.save(ChatRoomMember.createJoined(directChatRoom, member, "철수 채팅")); + chatRoomMemberRepository.save(ChatRoomMember.createJoined(directChatRoom, otherMember, member.getMemberName())); + ChatMessage unreadDirectMessage = chatMessageRepository.save( + ChatFixture.createTextMessage(directChatRoom, otherMember, "읽지 않은 개인 메시지")); + + ChatRoom pendingDirectRoom = chatRoomRepository.save(ChatFixture.createDirectChatRoom()); + chatRoomMemberRepository.save(ChatRoomMember.createPending(pendingDirectRoom, member, "보류 중인 채팅")); + chatRoomMemberRepository.save(ChatRoomMember.createJoined(pendingDirectRoom, otherMember, member.getMemberName())); + ChatMessage pendingDirectMessage = chatMessageRepository.save( + ChatFixture.createTextMessage(pendingDirectRoom, otherMember, "제외되어야 하는 개인 메시지")); + + messageReadStatusRepository.saveAll(List.of( + MessageReadStatus.createUnread(alreadyReadPartyMessage.getId(), member.getId(), partyChatRoom.getId()), + MessageReadStatus.createUnread(firstUnreadPartyMessage.getId(), member.getId(), partyChatRoom.getId()), + MessageReadStatus.createUnread(secondUnreadPartyMessage.getId(), member.getId(), partyChatRoom.getId()), + MessageReadStatus.createUnread(secondUnreadPartyMessage.getId(), otherMember.getId(), partyChatRoom.getId()), + MessageReadStatus.createUnread(unreadDirectMessage.getId(), member.getId(), directChatRoom.getId()), + MessageReadStatus.createUnread(pendingDirectMessage.getId(), member.getId(), pendingDirectRoom.getId()) + )); + } + + private void createExcludedUnreadStatusScenario() { + ChatRoomMember partyMembership = chatRoomMemberRepository.save( + ChatRoomMember.create(partyChatRoom, member)); + chatRoomMemberRepository.save(ChatRoomMember.create(partyChatRoom, otherMember)); + + ChatMessage lastReadPartyMessage = chatMessageRepository.save( + ChatFixture.createTextMessage(partyChatRoom, otherMember, "마지막으로 읽은 모임 메시지")); + partyMembership.updateLastReadMessageId(lastReadPartyMessage.getId()); + chatRoomMemberRepository.saveAndFlush(partyMembership); + + ChatRoom pendingDirectRoom = chatRoomRepository.save(ChatFixture.createDirectChatRoom()); + chatRoomMemberRepository.save(ChatRoomMember.createPending(pendingDirectRoom, member, "보류 중인 채팅")); + chatRoomMemberRepository.save(ChatRoomMember.createJoined(pendingDirectRoom, otherMember, member.getMemberName())); + ChatMessage pendingDirectMessage = chatMessageRepository.save( + ChatFixture.createTextMessage(pendingDirectRoom, otherMember, "제외되어야 하는 개인 메시지")); + + messageReadStatusRepository.saveAll(List.of( + MessageReadStatus.createUnread(lastReadPartyMessage.getId(), member.getId(), partyChatRoom.getId()), + MessageReadStatus.createUnread(lastReadPartyMessage.getId(), otherMember.getId(), partyChatRoom.getId()), + MessageReadStatus.createUnread(pendingDirectMessage.getId(), member.getId(), pendingDirectRoom.getId()) + )); + } } diff --git a/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketCommandHandlerTest.java b/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketCommandHandlerTest.java new file mode 100644 index 000000000..44688cc16 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketCommandHandlerTest.java @@ -0,0 +1,97 @@ +package umc.cockple.demo.domain.chat.presentation.websocket; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.web.socket.WebSocketSession; +import umc.cockple.demo.domain.chat.dto.WebSocketMessageDTO; +import umc.cockple.demo.domain.chat.enums.WebSocketMessageType; +import umc.cockple.demo.domain.chat.events.ChatMessageSendEvent; +import umc.cockple.demo.domain.chat.service.ChatValidator; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.then; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ChatWebSocketCommandHandler") +class ChatWebSocketCommandHandlerTest { + + @Mock private ChatValidator chatValidator; + @Mock private WebSocketResponseSender webSocketResponseSender; + @Mock private ApplicationEventPublisher eventPublisher; + @Mock private WebSocketSession session; + + private ChatWebSocketCommandHandler commandHandler; + + @BeforeEach + void setUp() { + commandHandler = new ChatWebSocketCommandHandler( + chatValidator, + webSocketResponseSender, + eventPublisher + ); + } + + @Test + @DisplayName("SEND 요청을 검증한 뒤 메시지 전송 이벤트를 발행한다") + void handleSend_publishesChatMessageSendEvent() { + // given + Long memberId = 10L; + Long chatRoomId = 20L; + WebSocketMessageDTO.Request request = new WebSocketMessageDTO.Request( + WebSocketMessageType.SEND, + chatRoomId, + null, + "hello", + List.of(), + null + ); + + // when + commandHandler.handle(session, request, memberId); + + // then + then(chatValidator).should().validateSendRequest(chatRoomId, "hello", List.of(), memberId); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(ChatMessageSendEvent.class); + then(eventPublisher).should().publishEvent(eventCaptor.capture()); + ChatMessageSendEvent event = eventCaptor.getValue(); + assertThat(event.chatRoomId()).isEqualTo(chatRoomId); + assertThat(event.senderId()).isEqualTo(memberId); + assertThat(event.content()).isEqualTo("hello"); + assertThat(event.files()).isEmpty(); + + then(webSocketResponseSender).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("처리 대상이 아닌 타입이면 UNKNOWN_TYPE 오류 응답을 보낸다") + void handle_sendsUnknownTypeError_whenRequestTypeIsNotCommand() { + // given + Long memberId = 10L; + WebSocketMessageDTO.Request request = new WebSocketMessageDTO.Request( + WebSocketMessageType.ERROR, + null, + null, + null, + null, + null + ); + + // when + commandHandler.handle(session, request, memberId); + + // then + then(webSocketResponseSender).should() + .sendErrorMessage(session, "UNKNOWN_TYPE", "알 수 없는 메시지 타입입니다:" + WebSocketMessageType.ERROR); + then(chatValidator).shouldHaveNoInteractions(); + then(eventPublisher).shouldHaveNoInteractions(); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketHandlerTest.java b/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketHandlerTest.java new file mode 100644 index 000000000..dab5123ba --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketHandlerTest.java @@ -0,0 +1,58 @@ +package umc.cockple.demo.domain.chat.presentation.websocket; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.WebSocketSession; +import umc.cockple.demo.domain.chat.presentation.websocket.session.WebSocketSessionRegistry; +import umc.cockple.demo.domain.member.service.MemberQueryService; + +import java.util.HashMap; +import java.util.Map; + +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ChatWebSocketHandler") +class ChatWebSocketHandlerTest { + + @Mock private MemberQueryService memberQueryService; + @Mock private WebSocketResponseSender webSocketResponseSender; + @Mock private ChatWebSocketRequestDispatcher requestDispatcher; + @Mock private WebSocketSessionRegistry sessionRegistry; + @Mock private WebSocketSession session; + + private ChatWebSocketHandler handler; + + @BeforeEach + void setUp() { + handler = new ChatWebSocketHandler( + memberQueryService, + webSocketResponseSender, + requestDispatcher, + sessionRegistry + ); + } + + @Test + @DisplayName("연결 종료 시 닫힌 세션과 일치할 때만 세션 저장소에서 제거한다") + void afterConnectionClosed_removesClosedSessionByIdentity() { + // given + Long memberId = 10L; + Map attributes = new HashMap<>(); + attributes.put("memberId", memberId); + given(session.getAttributes()).willReturn(attributes); + given(session.getId()).willReturn("session-1"); + + // when + handler.afterConnectionClosed(session, CloseStatus.NORMAL); + + // then + then(sessionRegistry).should().remove(memberId, session); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketRequestDispatcherTest.java b/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketRequestDispatcherTest.java new file mode 100644 index 000000000..467334a9a --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketRequestDispatcherTest.java @@ -0,0 +1,108 @@ +package umc.cockple.demo.domain.chat.presentation.websocket; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.socket.WebSocketSession; +import umc.cockple.demo.domain.chat.dto.WebSocketMessageDTO; +import umc.cockple.demo.domain.chat.enums.WebSocketMessageType; + +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.then; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ChatWebSocketRequestDispatcher") +class ChatWebSocketRequestDispatcherTest { + + @Mock private WebSocketResponseSender webSocketResponseSender; + @Mock private ChatWebSocketCommandHandler commandHandler; + @Mock private WebSocketSession session; + + private ChatWebSocketRequestDispatcher dispatcher; + + @BeforeEach + void setUp() { + dispatcher = new ChatWebSocketRequestDispatcher( + new ObjectMapper(), + webSocketResponseSender, + commandHandler + ); + } + + @Test + @DisplayName("인증된 요청을 파싱해 command handler에 위임한다") + void dispatch_delegatesAuthenticatedRequestToCommandHandler() { + // given + Long memberId = 10L; + Long chatRoomId = 20L; + Map attributes = new HashMap<>(); + attributes.put("memberId", memberId); + given(session.getAttributes()).willReturn(attributes); + given(session.getId()).willReturn("session-1"); + + String payload = """ + {"type":"SEND","chatRoomId":20,"content":"hello","images":[]} + """; + + // when + dispatcher.dispatch(session, payload); + + // then + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(WebSocketMessageDTO.Request.class); + then(commandHandler).should().handle(eq(session), requestCaptor.capture(), eq(memberId)); + + WebSocketMessageDTO.Request request = requestCaptor.getValue(); + assertThat(request.type()).isEqualTo(WebSocketMessageType.SEND); + assertThat(request.chatRoomId()).isEqualTo(chatRoomId); + assertThat(request.content()).isEqualTo("hello"); + assertThat(request.images()).isEmpty(); + + then(webSocketResponseSender).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("memberId가 없으면 인증 오류 응답을 전송하고 라우팅하지 않는다") + void dispatch_sendsUnauthorizedError_whenMemberIdMissing() { + // given + given(session.getAttributes()).willReturn(new HashMap<>()); + String payload = """ + {"type":"SEND","chatRoomId":20,"content":"hello","images":[]} + """; + + // when + dispatcher.dispatch(session, payload); + + // then + then(webSocketResponseSender).should() + .sendErrorMessage(session, "UNAUTHORIZED", "인증되지 않은 사용자입니다."); + then(commandHandler).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("파싱 실패 시 내부 예외 메시지 없이 일반 처리 오류 응답을 전송한다") + void dispatch_sendsGenericProcessingError_whenPayloadCannotBeParsed() { + // given + given(session.getId()).willReturn("session-1"); + String invalidPayload = "{invalid-json"; + + // when + dispatcher.dispatch(session, invalidPayload); + + // then + then(webSocketResponseSender).should() + .sendErrorMessage(session, "PROCESSING_ERROR", "메시지 처리 중 오류가 발생했습니다."); + then(commandHandler).shouldHaveNoInteractions(); + } + +} diff --git a/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/JWTWebSocketAuthInterceptorTest.java b/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/JWTWebSocketAuthInterceptorTest.java new file mode 100644 index 000000000..9b5177354 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/JWTWebSocketAuthInterceptorTest.java @@ -0,0 +1,64 @@ +package umc.cockple.demo.domain.chat.presentation.websocket; + +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.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.slf4j.MDC; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.web.socket.WebSocketHandler; +import umc.cockple.demo.global.logging.MdcLoggingFilter; +import umc.cockple.demo.global.security.filter.JwtAuthenticationFilter; +import umc.cockple.demo.global.jwt.domain.JwtTokenProvider; + +import java.net.URI; +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +@DisplayName("JWTWebSocketAuthInterceptor") +class JWTWebSocketAuthInterceptorTest { + + @Mock private JwtTokenProvider jwtTokenProvider; + @Mock private ServerHttpRequest request; + @Mock private ServerHttpResponse response; + @Mock private WebSocketHandler wsHandler; + + private JWTWebSocketAuthInterceptor interceptor; + + @BeforeEach + void setUp() { + interceptor = new JWTWebSocketAuthInterceptor(jwtTokenProvider); + } + + @AfterEach + void tearDown() { + MDC.clear(); + } + + @Test + @DisplayName("handshake 인증 성공 시 세션 attributes에 memberId를 저장하고 MDC를 복구한다") + void beforeHandshakeStoresMemberIdAndRestoresMdc() throws Exception { + Map attributes = new HashMap<>(); + given(request.getURI()).willReturn(URI.create("/ws/chats?token=access-token")); + given(jwtTokenProvider.validateToken("access-token")).willReturn(true); + given(jwtTokenProvider.getUserId("access-token")).willReturn(10L); + MDC.put(MdcLoggingFilter.REQUEST_ID, "request-1"); + + boolean result = interceptor.beforeHandshake(request, response, wsHandler, attributes); + + assertThat(result).isTrue(); + assertThat(attributes).containsEntry(JwtAuthenticationFilter.MEMBER_ID, 10L); + assertThat(attributes).containsEntry("authenticated", true); + assertThat(MDC.get(MdcLoggingFilter.REQUEST_ID)).isEqualTo("request-1"); + assertThat(MDC.get(JwtAuthenticationFilter.MEMBER_ID)).isNull(); + assertThat(MDC.get(WebSocketMdcSupport.WS_SESSION_ID)).isNull(); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/WebSocketMdcSupportTest.java b/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/WebSocketMdcSupportTest.java new file mode 100644 index 000000000..7eb3b329e --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/WebSocketMdcSupportTest.java @@ -0,0 +1,63 @@ +package umc.cockple.demo.domain.chat.presentation.websocket; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; +import org.springframework.web.socket.WebSocketSession; +import umc.cockple.demo.global.logging.MdcLoggingFilter; +import umc.cockple.demo.global.security.filter.JwtAuthenticationFilter; + +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +@DisplayName("WebSocketMdcSupport") +class WebSocketMdcSupportTest { + + @AfterEach + void tearDown() { + MDC.clear(); + } + + @Test + @DisplayName("세션 scope 동안 memberId와 wsSessionId를 MDC에 넣고 종료 후 이전 MDC를 복구한다") + void openSessionMdcAndRestorePreviousContext() { + WebSocketSession session = mock(WebSocketSession.class); + Map attributes = new HashMap<>(); + attributes.put(JwtAuthenticationFilter.MEMBER_ID, 10L); + given(session.getId()).willReturn("session-1"); + given(session.getAttributes()).willReturn(attributes); + MDC.put(MdcLoggingFilter.REQUEST_ID, "request-1"); + + try (WebSocketMdcSupport.MdcScope ignored = WebSocketMdcSupport.open(session)) { + assertThat(MDC.get(JwtAuthenticationFilter.MEMBER_ID)).isEqualTo("10"); + assertThat(MDC.get(WebSocketMdcSupport.WS_SESSION_ID)).isEqualTo("session-1"); + assertThat(MDC.get(MdcLoggingFilter.REQUEST_ID)).isEqualTo("request-1"); + } + + assertThat(MDC.get(MdcLoggingFilter.REQUEST_ID)).isEqualTo("request-1"); + assertThat(MDC.get(JwtAuthenticationFilter.MEMBER_ID)).isNull(); + assertThat(MDC.get(WebSocketMdcSupport.WS_SESSION_ID)).isNull(); + } + + @Test + @DisplayName("memberId scope는 wsSessionId를 비우고 종료 후 이전 MDC를 복구한다") + void openMemberMdcAndClearWebSocketSessionId() { + MDC.put(MdcLoggingFilter.REQUEST_ID, "request-1"); + MDC.put(WebSocketMdcSupport.WS_SESSION_ID, "stale-session"); + + try (WebSocketMdcSupport.MdcScope ignored = WebSocketMdcSupport.open(20L)) { + assertThat(MDC.get(JwtAuthenticationFilter.MEMBER_ID)).isEqualTo("20"); + assertThat(MDC.get(WebSocketMdcSupport.WS_SESSION_ID)).isNull(); + assertThat(MDC.get(MdcLoggingFilter.REQUEST_ID)).isEqualTo("request-1"); + } + + assertThat(MDC.get(MdcLoggingFilter.REQUEST_ID)).isEqualTo("request-1"); + assertThat(MDC.get(WebSocketMdcSupport.WS_SESSION_ID)).isEqualTo("stale-session"); + assertThat(MDC.get(JwtAuthenticationFilter.MEMBER_ID)).isNull(); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/WebSocketResponseSenderTest.java b/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/WebSocketResponseSenderTest.java new file mode 100644 index 000000000..95d31174a --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/WebSocketResponseSenderTest.java @@ -0,0 +1,77 @@ +package umc.cockple.demo.domain.chat.presentation.websocket; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.socket.WebSocketSession; +import umc.cockple.demo.domain.chat.dto.MemberConnectionInfo; +import umc.cockple.demo.domain.chat.dto.WebSocketMessageDTO; +import umc.cockple.demo.domain.chat.enums.WebSocketMessageType; +import umc.cockple.demo.domain.chat.presentation.websocket.session.WebSocketSessionMessageSender; +import umc.cockple.demo.domain.chat.service.websocket.session.ChatMessageEncoder; +import umc.cockple.demo.domain.chat.service.websocket.session.EncodedChatMessage; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +@ExtendWith(MockitoExtension.class) +@DisplayName("WebSocketResponseSender") +class WebSocketResponseSenderTest { + + @Mock private ChatMessageEncoder messageEncoder; + @Mock private WebSocketSessionMessageSender sessionMessageSender; + @Mock private WebSocketSession session; + + private WebSocketResponseSender responseSender; + + @BeforeEach + void setUp() { + responseSender = new WebSocketResponseSender(messageEncoder, sessionMessageSender); + } + + @Test + @DisplayName("연결 성공 응답을 조립해 인코딩 후 현재 세션에 전송한다") + void sendConnectionSuccessMessage_encodesAndSendsConnectionInfo() { + // given + MemberConnectionInfo memberInfo = new MemberConnectionInfo(10L, "홍길동"); + EncodedChatMessage encodedMessage = new EncodedChatMessage("connection-json"); + given(messageEncoder.encode(any(WebSocketMessageDTO.ConnectionInfo.class))) + .willReturn(Optional.of(encodedMessage)); + + // when + responseSender.sendConnectionSuccessMessage(session, memberInfo); + + // then + ArgumentCaptor messageCaptor = + ArgumentCaptor.forClass(WebSocketMessageDTO.ConnectionInfo.class); + then(messageEncoder).should().encode(messageCaptor.capture()); + WebSocketMessageDTO.ConnectionInfo message = messageCaptor.getValue(); + assertThat(message.type()).isEqualTo(WebSocketMessageType.CONNECT); + assertThat(message.memberId()).isEqualTo(memberInfo.memberId()); + assertThat(message.memberName()).isEqualTo(memberInfo.memberName()); + + then(sessionMessageSender).should().send(session, encodedMessage); + } + + @Test + @DisplayName("닫힌 세션에는 에러 응답을 인코딩하지 않는다") + void sendErrorMessage_doesNotEncodeWhenSessionClosed() { + // given + given(session.isOpen()).willReturn(false); + + // when + responseSender.sendErrorMessage(session, "ERROR", "오류"); + + // then + then(messageEncoder).shouldHaveNoInteractions(); + then(sessionMessageSender).shouldHaveNoInteractions(); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/session/JacksonChatMessageEncoderTest.java b/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/session/JacksonChatMessageEncoderTest.java new file mode 100644 index 000000000..3134fa459 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/session/JacksonChatMessageEncoderTest.java @@ -0,0 +1,41 @@ +package umc.cockple.demo.domain.chat.presentation.websocket.session; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import umc.cockple.demo.domain.chat.dto.WebSocketMessageDTO; +import umc.cockple.demo.domain.chat.enums.WebSocketMessageType; +import umc.cockple.demo.domain.chat.service.websocket.session.EncodedChatMessage; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("JacksonChatMessageEncoder") +class JacksonChatMessageEncoderTest { + + private final JacksonChatMessageEncoder messageEncoder = + new JacksonChatMessageEncoder(new ObjectMapper().findAndRegisterModules()); + + @Test + @DisplayName("WebSocket 메시지를 전송 가능한 payload로 인코딩한다") + void encode_serializesMessagePayload() { + // given + WebSocketMessageDTO.UnreadStatusUpdateMessage message = + WebSocketMessageDTO.UnreadStatusUpdateMessage.builder() + .type(WebSocketMessageType.UNREAD_STATUS_UPDATE) + .hasUnread(true) + .hasPartyUnread(true) + .hasDirectUnread(false) + .timestamp(LocalDateTime.of(2026, 5, 21, 13, 15)) + .build(); + + // when + Optional encodedMessage = messageEncoder.encode(message); + + // then + assertThat(encodedMessage).isPresent(); + assertThat(encodedMessage.get().payload()).contains("\"type\":\"UNREAD_STATUS_UPDATE\""); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/session/WebSocketMessageSenderTest.java b/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/session/WebSocketMessageSenderTest.java new file mode 100644 index 000000000..0f08d1f61 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/session/WebSocketMessageSenderTest.java @@ -0,0 +1,85 @@ +package umc.cockple.demo.domain.chat.presentation.websocket.session; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.socket.WebSocketSession; +import umc.cockple.demo.domain.chat.service.websocket.session.EncodedChatMessage; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +@ExtendWith(MockitoExtension.class) +@DisplayName("WebSocketMessageSender") +class WebSocketMessageSenderTest { + + @Mock private WebSocketSessionRegistry sessionRegistry; + @Mock private WebSocketSessionMessageSender sessionMessageSender; + @Mock private WebSocketSession session; + + private WebSocketMessageSender messageSender; + + @BeforeEach + void setUp() { + messageSender = new WebSocketMessageSender(sessionRegistry, sessionMessageSender); + } + + @Test + @DisplayName("인코딩된 메시지를 열린 세션에 전송한다") + void send_sendsEncodedMessageToOpenSession() { + // given + Long memberId = 10L; + given(sessionRegistry.findOpenSession(memberId)).willReturn(Optional.of(session)); + EncodedChatMessage message = new EncodedChatMessage("{\"type\":\"UNREAD_STATUS_UPDATE\"}"); + given(sessionMessageSender.send(session, message)).willReturn(true); + + // when + boolean sent = messageSender.send(memberId, message); + + // then + assertThat(sent).isTrue(); + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(EncodedChatMessage.class); + then(sessionMessageSender).should().send(org.mockito.ArgumentMatchers.eq(session), messageCaptor.capture()); + assertThat(messageCaptor.getValue().payload()).contains("\"type\":\"UNREAD_STATUS_UPDATE\""); + } + + @Test + @DisplayName("대상 세션이 없으면 실패만 반환한다") + void send_returnsFalseWhenSessionDoesNotExist() { + // given + Long memberId = 10L; + given(sessionRegistry.findOpenSession(memberId)).willReturn(Optional.empty()); + + // when + boolean sent = messageSender.send(memberId, new EncodedChatMessage("{}")); + + // then + assertThat(sent).isFalse(); + then(sessionRegistry).should().findOpenSession(memberId); + then(sessionRegistry).shouldHaveNoMoreInteractions(); + } + + @Test + @DisplayName("전송 중 예외가 발생하면 세션 저장소에서 제거하고 실패를 반환한다") + void send_removesMemberWhenSendFails() throws Exception { + // given + Long memberId = 10L; + given(sessionRegistry.findOpenSession(memberId)).willReturn(Optional.of(session)); + EncodedChatMessage message = new EncodedChatMessage("{}"); + given(sessionMessageSender.send(session, message)).willReturn(false); + + // when + boolean sent = messageSender.send(memberId, message); + + // then + assertThat(sent).isFalse(); + then(sessionRegistry).should().remove(memberId, session); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/session/WebSocketSessionMessageSenderTest.java b/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/session/WebSocketSessionMessageSenderTest.java new file mode 100644 index 000000000..57ad7e05a --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/session/WebSocketSessionMessageSenderTest.java @@ -0,0 +1,54 @@ +package umc.cockple.demo.domain.chat.presentation.websocket.session; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import umc.cockple.demo.domain.chat.service.websocket.session.EncodedChatMessage; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.mock; + +@DisplayName("WebSocketSessionMessageSender") +class WebSocketSessionMessageSenderTest { + + private final WebSocketSessionMessageSender sessionMessageSender = new WebSocketSessionMessageSender(); + + @Test + @DisplayName("인코딩된 payload를 WebSocket TextMessage로 전송한다") + void send_sendsTextMessagePayload() throws Exception { + // given + WebSocketSession session = mock(WebSocketSession.class); + EncodedChatMessage message = new EncodedChatMessage("{\"type\":\"CONNECT\"}"); + + // when + boolean sent = sessionMessageSender.send(session, message); + + // then + assertThat(sent).isTrue(); + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(TextMessage.class); + then(session).should().sendMessage(messageCaptor.capture()); + assertThat(messageCaptor.getValue().getPayload()).isEqualTo(message.payload()); + } + + @Test + @DisplayName("전송 실패 시 false를 반환한다") + void send_returnsFalseWhenSendFails() throws Exception { + // given + WebSocketSession session = mock(WebSocketSession.class); + given(session.getId()).willReturn("session-1"); + willThrow(new RuntimeException("socket closed")) + .given(session) + .sendMessage(org.mockito.ArgumentMatchers.any(TextMessage.class)); + + // when + boolean sent = sessionMessageSender.send(session, new EncodedChatMessage("{}")); + + // then + assertThat(sent).isFalse(); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/session/WebSocketSessionRegistryTest.java b/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/session/WebSocketSessionRegistryTest.java new file mode 100644 index 000000000..1fd577eb5 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/session/WebSocketSessionRegistryTest.java @@ -0,0 +1,102 @@ +package umc.cockple.demo.domain.chat.presentation.websocket.session; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.web.socket.WebSocketSession; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +@DisplayName("WebSocketSessionRegistry") +class WebSocketSessionRegistryTest { + + private final WebSocketSessionRegistry sessionRegistry = new WebSocketSessionRegistry(); + + @Test + @DisplayName("멤버별 열린 세션을 저장하고 조회한다") + void findOpenSession_returnsRegisteredOpenSession() { + // given + Long memberId = 10L; + WebSocketSession session = mock(WebSocketSession.class); + given(session.isOpen()).willReturn(true); + + // when + sessionRegistry.register(memberId, session); + + // then + assertThat(sessionRegistry.findOpenSession(memberId)).contains(session); + } + + @Test + @DisplayName("열린 세션이 있는 멤버만 필터링한다") + void findOpenMemberIds_filtersMembersWithOpenSessions() { + // given + WebSocketSession openSession = mock(WebSocketSession.class); + WebSocketSession closedSession = mock(WebSocketSession.class); + given(openSession.isOpen()).willReturn(true); + given(closedSession.isOpen()).willReturn(false); + + sessionRegistry.register(10L, openSession); + sessionRegistry.register(20L, closedSession); + + // when + List openMemberIds = sessionRegistry.findOpenMemberIds(List.of(10L, 20L, 30L)); + + // then + assertThat(openMemberIds).containsExactly(10L); + } + + @Test + @DisplayName("등록된 세션과 같은 세션을 제거하면 조회되지 않는다") + void remove_deletesRegisteredSessionWhenSameSession() { + // given + Long memberId = 10L; + WebSocketSession session = mock(WebSocketSession.class); + given(session.isOpen()).willReturn(true); + sessionRegistry.register(memberId, session); + + // when + sessionRegistry.remove(memberId, session); + + // then + assertThat(sessionRegistry.findOpenSession(memberId)).isEmpty(); + } + + @Test + @DisplayName("오래된 세션 제거 요청은 최신 세션을 제거하지 않는다") + void remove_doesNotDeleteNewSessionWhenOldSessionCloses() { + // given + Long memberId = 10L; + WebSocketSession oldSession = mock(WebSocketSession.class); + WebSocketSession newSession = mock(WebSocketSession.class); + given(newSession.isOpen()).willReturn(true); + + sessionRegistry.register(memberId, oldSession); + sessionRegistry.register(memberId, newSession); + + // when + sessionRegistry.remove(memberId, oldSession); + + // then + assertThat(sessionRegistry.findOpenSession(memberId)).contains(newSession); + } + + @Test + @DisplayName("닫힌 세션 조회 시 같은 세션일 때만 저장소에서 정리한다") + void findOpenSession_removesClosedSessionOnlyWhenStillRegistered() { + // given + Long memberId = 10L; + WebSocketSession closedSession = mock(WebSocketSession.class); + given(closedSession.isOpen()).willReturn(false); + sessionRegistry.register(memberId, closedSession); + + // when + assertThat(sessionRegistry.findOpenSession(memberId)).isEmpty(); + + // then + assertThat(sessionRegistry.findOpenSession(memberId)).isEmpty(); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/chat/repository/ChatRoomMemberRepositoryTest.java b/src/test/java/umc/cockple/demo/domain/chat/repository/ChatRoomMemberRepositoryTest.java new file mode 100644 index 000000000..e45ddc29e --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/chat/repository/ChatRoomMemberRepositoryTest.java @@ -0,0 +1,102 @@ +package umc.cockple.demo.domain.chat.repository; + +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.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.context.annotation.Import; +import umc.cockple.demo.domain.chat.domain.ChatRoom; +import umc.cockple.demo.domain.chat.domain.ChatRoomMember; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.global.config.QuerydslConfig; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.support.fixture.ChatFixture; +import umc.cockple.demo.support.fixture.MemberFixture; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("ChatRoomMemberRepository") +@DataJpaTest +@Import(QuerydslConfig.class) +class ChatRoomMemberRepositoryTest { + + @Autowired private ChatRoomMemberRepository chatRoomMemberRepository; + @Autowired private ChatRoomRepository chatRoomRepository; + @Autowired private MemberRepository memberRepository; + @Autowired private TestEntityManager entityManager; + + @Test + @DisplayName("advanceLastReadMessageId는 기존 값이 없으면 새 메시지 ID로 설정한다") + void advanceLastReadMessageId_updatesNullValue() { + // given + Member member = memberRepository.save(MemberFixture.createMember("홍길동", Gender.MALE, Level.A, 1001L)); + ChatRoom chatRoom = chatRoomRepository.save(ChatFixture.createDirectChatRoom()); + ChatRoomMember membership = + chatRoomMemberRepository.save(ChatRoomMember.createJoined(chatRoom, member, "상대방")); + + // when + int updatedCount = chatRoomMemberRepository.advanceLastReadMessageId(chatRoom.getId(), member.getId(), 100L); + + // then + assertThat(updatedCount).isEqualTo(1); + entityManager.clear(); + ChatRoomMember updatedMembership = chatRoomMemberRepository.findById(membership.getId()).orElseThrow(); + assertThat(updatedMembership.getLastReadMessageId()).isEqualTo(100L); + } + + @Test + @DisplayName("advanceLastReadMessageId는 기존 값보다 작은 메시지 ID로 감소시키지 않는다") + void advanceLastReadMessageId_doesNotMoveBackward() { + // given + Member member = memberRepository.save(MemberFixture.createMember("홍길동", Gender.MALE, Level.A, 1001L)); + ChatRoom chatRoom = chatRoomRepository.save(ChatFixture.createDirectChatRoom()); + ChatRoomMember membership = chatRoomMemberRepository.save( + ChatFixture.createJoinedMemberWithLastRead(chatRoom, member, 200L)); + + // when + int updatedCount = chatRoomMemberRepository.advanceLastReadMessageId(chatRoom.getId(), member.getId(), 100L); + + // then + assertThat(updatedCount).isEqualTo(0); + entityManager.clear(); + ChatRoomMember updatedMembership = chatRoomMemberRepository.findById(membership.getId()).orElseThrow(); + assertThat(updatedMembership.getLastReadMessageId()).isEqualTo(200L); + } + + @Test + @DisplayName("advanceLastReadMessageIdForMembers는 여러 멤버의 lastReadMessageId를 전진시킨다") + void advanceLastReadMessageIdForMembers_updatesMultipleMembers() { + // given + Member firstMember = memberRepository.save(MemberFixture.createMember("첫번째", Gender.MALE, Level.A, 1001L)); + Member secondMember = memberRepository.save(MemberFixture.createMember("두번째", Gender.FEMALE, Level.B, 2002L)); + Member skippedMember = memberRepository.save(MemberFixture.createMember("스킵", Gender.MALE, Level.C, 3003L)); + ChatRoom chatRoom = chatRoomRepository.save(ChatFixture.createDirectChatRoom()); + ChatRoomMember firstMembership = + chatRoomMemberRepository.save(ChatRoomMember.createJoined(chatRoom, firstMember, "첫번째")); + ChatRoomMember secondMembership = chatRoomMemberRepository.save( + ChatFixture.createJoinedMemberWithLastRead(chatRoom, secondMember, 50L)); + ChatRoomMember skippedMembership = chatRoomMemberRepository.save( + ChatFixture.createJoinedMemberWithLastRead(chatRoom, skippedMember, 300L)); + + // when + int updatedCount = chatRoomMemberRepository.advanceLastReadMessageIdForMembers( + chatRoom.getId(), + List.of(firstMember.getId(), secondMember.getId(), skippedMember.getId()), + 200L); + + // then + assertThat(updatedCount).isEqualTo(2); + entityManager.clear(); + assertThat(chatRoomMemberRepository.findById(firstMembership.getId()).orElseThrow().getLastReadMessageId()) + .isEqualTo(200L); + assertThat(chatRoomMemberRepository.findById(secondMembership.getId()).orElseThrow().getLastReadMessageId()) + .isEqualTo(200L); + assertThat(chatRoomMemberRepository.findById(skippedMembership.getId()).orElseThrow().getLastReadMessageId()) + .isEqualTo(300L); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/chat/repository/MessageReadStatusRepositoryTest.java b/src/test/java/umc/cockple/demo/domain/chat/repository/MessageReadStatusRepositoryTest.java new file mode 100644 index 000000000..6fb6e713b --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/chat/repository/MessageReadStatusRepositoryTest.java @@ -0,0 +1,115 @@ +package umc.cockple.demo.domain.chat.repository; + +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.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.context.annotation.Import; +import umc.cockple.demo.domain.chat.domain.MessageReadStatus; +import umc.cockple.demo.domain.chat.repository.projection.ChatMessageUnreadCountDTO; +import umc.cockple.demo.global.config.QuerydslConfig; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("MessageReadStatusRepository") +@DataJpaTest +@Import(QuerydslConfig.class) +class MessageReadStatusRepositoryTest { + + @Autowired private MessageReadStatusRepository messageReadStatusRepository; + @Autowired private TestEntityManager entityManager; + + @Test + @DisplayName("markMessagesAsReadForMember는 지정 채팅방/멤버/메시지 목록의 unread 상태만 읽음 처리한다") + void markMessagesAsReadForMember_updatesOnlyMatchingUnreadStatuses() { + // given + Long chatRoomId = 10L; + Long memberId = 101L; + Long otherMemberId = 102L; + Long otherChatRoomId = 11L; + Long firstMessageId = 201L; + Long secondMessageId = 202L; + Long excludedMessageId = 203L; + Long otherRoomMessageId = 204L; + + MessageReadStatus firstUnread = saveUnread(firstMessageId, memberId, chatRoomId); + MessageReadStatus secondUnread = saveUnread(secondMessageId, memberId, chatRoomId); + MessageReadStatus excludedUnread = saveUnread(excludedMessageId, memberId, chatRoomId); + MessageReadStatus otherMemberUnread = saveUnread(firstMessageId, otherMemberId, chatRoomId); + MessageReadStatus otherRoomUnread = saveUnread(otherRoomMessageId, memberId, otherChatRoomId); + MessageReadStatus alreadyRead = saveRead(205L, memberId, chatRoomId); + + // when + int updatedCount = messageReadStatusRepository.markMessagesAsReadForMember( + chatRoomId, + memberId, + List.of(firstMessageId, secondMessageId, otherRoomMessageId) + ); + + // then + assertThat(updatedCount).isEqualTo(2); + entityManager.clear(); + + assertThat(readStatus(firstUnread).getIsRead()).isTrue(); + assertThat(readStatus(secondUnread).getIsRead()).isTrue(); + assertThat(readStatus(excludedUnread).getIsRead()).isFalse(); + assertThat(readStatus(otherMemberUnread).getIsRead()).isFalse(); + assertThat(readStatus(otherRoomUnread).getIsRead()).isFalse(); + assertThat(readStatus(alreadyRead).getIsRead()).isTrue(); + } + + @Test + @DisplayName("countUnreadByMessageIds는 메시지별 unread 수를 그룹으로 조회하고 fully-read 메시지는 결과에서 제외한다") + void countUnreadByMessageIds_countsUnreadByMessageAndOmitsFullyReadMessages() { + // given + Long chatRoomId = 10L; + Long firstMessageId = 201L; + Long secondMessageId = 202L; + Long fullyReadMessageId = 203L; + Long excludedMessageId = 204L; + + saveUnread(firstMessageId, 101L, chatRoomId); + saveUnread(firstMessageId, 102L, chatRoomId); + saveRead(firstMessageId, 103L, chatRoomId); + saveUnread(secondMessageId, 101L, chatRoomId); + saveRead(fullyReadMessageId, 101L, chatRoomId); + saveRead(fullyReadMessageId, 102L, chatRoomId); + saveUnread(excludedMessageId, 101L, chatRoomId); + + // when + List result = messageReadStatusRepository.countUnreadByMessageIds( + List.of(firstMessageId, secondMessageId, fullyReadMessageId) + ); + + // then + assertThat(result) + .extracting(ChatMessageUnreadCountDTO::chatMessageId) + .containsExactlyInAnyOrder(firstMessageId, secondMessageId); + assertThat(result) + .anySatisfy(count -> { + assertThat(count.chatMessageId()).isEqualTo(firstMessageId); + assertThat(count.unreadCount()).isEqualTo(2L); + }) + .anySatisfy(count -> { + assertThat(count.chatMessageId()).isEqualTo(secondMessageId); + assertThat(count.unreadCount()).isEqualTo(1L); + }) + .noneSatisfy(count -> assertThat(count.chatMessageId()).isEqualTo(fullyReadMessageId)) + .noneSatisfy(count -> assertThat(count.chatMessageId()).isEqualTo(excludedMessageId)); + } + + private MessageReadStatus saveUnread(Long messageId, Long memberId, Long chatRoomId) { + return messageReadStatusRepository.save(MessageReadStatus.createUnread(messageId, memberId, chatRoomId)); + } + + private MessageReadStatus saveRead(Long messageId, Long memberId, Long chatRoomId) { + return messageReadStatusRepository.save(MessageReadStatus.createRead(messageId, memberId, chatRoomId)); + } + + private MessageReadStatus readStatus(MessageReadStatus status) { + return messageReadStatusRepository.findById(status.getId()).orElseThrow(); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/chat/service/websocket/ChatListSubscriptionServiceTest.java b/src/test/java/umc/cockple/demo/domain/chat/repository/redis/ChatListSubscriptionStoreTest.java similarity index 75% rename from src/test/java/umc/cockple/demo/domain/chat/service/websocket/ChatListSubscriptionServiceTest.java rename to src/test/java/umc/cockple/demo/domain/chat/repository/redis/ChatListSubscriptionStoreTest.java index 5646a7321..d11bd36f0 100644 --- a/src/test/java/umc/cockple/demo/domain/chat/service/websocket/ChatListSubscriptionServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/chat/repository/redis/ChatListSubscriptionStoreTest.java @@ -1,4 +1,4 @@ -package umc.cockple.demo.domain.chat.service.websocket; +package umc.cockple.demo.domain.chat.repository.redis; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -13,11 +13,11 @@ import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) -@DisplayName("ChatListSubscriptionService 단위 테스트") -class ChatListSubscriptionServiceTest { +@DisplayName("ChatListSubscriptionStore 단위 테스트") +class ChatListSubscriptionStoreTest { @InjectMocks - private ChatListSubscriptionService chatListSubscriptionService; + private ChatListSubscriptionStore chatListSubscriptionStore; @Mock private StringRedisTemplate stringRedisTemplate; @@ -25,7 +25,7 @@ class ChatListSubscriptionServiceTest { @Test @DisplayName("채팅 목록 구독 키는 best-effort로 삭제한다") void tryClearChatListSubscribers_deletesKey() { - chatListSubscriptionService.tryClearChatListSubscribers(10L); + chatListSubscriptionStore.tryClearChatListSubscribers(10L); verify(stringRedisTemplate).delete("chatlist:subscribers:10"); } @@ -37,7 +37,7 @@ void tryClearChatListSubscribers_doesNotThrowWhenRedisFails() { .given(stringRedisTemplate) .delete("chatlist:subscribers:10"); - assertThatCode(() -> chatListSubscriptionService.tryClearChatListSubscribers(10L)) + assertThatCode(() -> chatListSubscriptionStore.tryClearChatListSubscribers(10L)) .doesNotThrowAnyException(); } } diff --git a/src/test/java/umc/cockple/demo/domain/chat/service/websocket/RedisSubscriptionServiceTest.java b/src/test/java/umc/cockple/demo/domain/chat/repository/redis/ChatRoomSubscriptionStoreTest.java similarity index 76% rename from src/test/java/umc/cockple/demo/domain/chat/service/websocket/RedisSubscriptionServiceTest.java rename to src/test/java/umc/cockple/demo/domain/chat/repository/redis/ChatRoomSubscriptionStoreTest.java index c38192af5..1e01e3178 100644 --- a/src/test/java/umc/cockple/demo/domain/chat/service/websocket/RedisSubscriptionServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/chat/repository/redis/ChatRoomSubscriptionStoreTest.java @@ -1,4 +1,4 @@ -package umc.cockple.demo.domain.chat.service.websocket; +package umc.cockple.demo.domain.chat.repository.redis; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -13,11 +13,11 @@ import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) -@DisplayName("RedisSubscriptionService 단위 테스트") -class RedisSubscriptionServiceTest { +@DisplayName("ChatRoomSubscriptionStore 단위 테스트") +class ChatRoomSubscriptionStoreTest { @InjectMocks - private RedisSubscriptionService redisSubscriptionService; + private ChatRoomSubscriptionStore chatRoomSubscriptionStore; @Mock private StringRedisTemplate stringRedisTemplate; @@ -25,7 +25,7 @@ class RedisSubscriptionServiceTest { @Test @DisplayName("채팅방 구독 키는 best-effort로 삭제한다") void tryClearRoomSubscribers_deletesKey() { - redisSubscriptionService.tryClearRoomSubscribers(10L); + chatRoomSubscriptionStore.tryClearRoomSubscribers(10L); verify(stringRedisTemplate).delete("chatroom:subscribers:10"); } @@ -37,7 +37,7 @@ void tryClearRoomSubscribers_doesNotThrowWhenRedisFails() { .given(stringRedisTemplate) .delete("chatroom:subscribers:10"); - assertThatCode(() -> redisSubscriptionService.tryClearRoomSubscribers(10L)) + assertThatCode(() -> chatRoomSubscriptionStore.tryClearRoomSubscribers(10L)) .doesNotThrowAnyException(); } } diff --git a/src/test/java/umc/cockple/demo/domain/chat/service/ChatQueryServiceTest.java b/src/test/java/umc/cockple/demo/domain/chat/service/ChatQueryServiceTest.java index 800cc474a..62088a673 100644 --- a/src/test/java/umc/cockple/demo/domain/chat/service/ChatQueryServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/chat/service/ChatQueryServiceTest.java @@ -19,6 +19,7 @@ import umc.cockple.demo.domain.chat.dto.DirectChatRoomDTO; import umc.cockple.demo.domain.chat.dto.ChatMessageDTO; import umc.cockple.demo.domain.chat.dto.ChatRoomDetailDTO; +import umc.cockple.demo.domain.chat.repository.projection.ChatRoomUnreadCountDTO; import umc.cockple.demo.domain.chat.dto.LastMessageCacheDTO; import umc.cockple.demo.domain.chat.dto.PartyChatRoomDTO; import umc.cockple.demo.domain.chat.enums.ChatRoomType; @@ -50,9 +51,11 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -71,24 +74,84 @@ class ChatQueryServiceTest { private ChatConverter chatConverter; private ChatProcessor chatProcessor; + private ChatUnreadQueryService chatUnreadQueryService; private ChatQueryServiceImpl chatQueryService; @BeforeEach void setUp() { chatConverter = new ChatConverter(); chatProcessor = new ChatProcessor(fileService, chatConverter); + chatUnreadQueryService = new ChatUnreadQueryService(messageReadStatusRepository); chatQueryService = new ChatQueryServiceImpl( chatRoomRepository, chatRoomMemberRepository, chatMessageRepository, partyRepository, memberPartyRepository, - messageReadStatusRepository, + chatUnreadQueryService, chatConverter, fileService, chatProcessor, chatRoomListCacheService ); + lenient().when(messageReadStatusRepository.countUnreadMessagesByChatRooms(anyLong(), anyList())) + .thenReturn(List.of()); + } + + @Nested + @DisplayName("안 읽은 메시지 여부 조회") + class GetUnreadStatus { + + @Test + @DisplayName("모임과 개인 안읽음 여부를 함께 반환한다") + void getUnreadStatus_returnsPartyAndDirectUnreadStatus() { + // given + Long memberId = 10L; + given(messageReadStatusRepository.existsPartyUnreadMessagesByMemberId(memberId)).willReturn(true); + given(messageReadStatusRepository.existsDirectUnreadMessagesByMemberId(memberId)).willReturn(false); + + // when + var result = chatQueryService.getUnreadStatus(memberId); + + // then + assertThat(result.hasUnread()).isTrue(); + assertThat(result.hasPartyUnread()).isTrue(); + assertThat(result.hasDirectUnread()).isFalse(); + } + + @Test + @DisplayName("모임과 개인 모두 안읽음이 없으면 전체 안읽음 여부가 false이다") + void getUnreadStatus_returnsFalseWhenNoUnreadExists() { + // given + Long memberId = 10L; + given(messageReadStatusRepository.existsPartyUnreadMessagesByMemberId(memberId)).willReturn(false); + given(messageReadStatusRepository.existsDirectUnreadMessagesByMemberId(memberId)).willReturn(false); + + // when + var result = chatQueryService.getUnreadStatus(memberId); + + // then + assertThat(result.hasUnread()).isFalse(); + assertThat(result.hasPartyUnread()).isFalse(); + assertThat(result.hasDirectUnread()).isFalse(); + } + + @Test + @DisplayName("개인 안읽음만 있으면 전체 안읽음 여부가 true이다") + void getUnreadStatus_returnsTrueWhenOnlyDirectUnreadExists() { + // given + Long memberId = 10L; + given(messageReadStatusRepository.existsPartyUnreadMessagesByMemberId(memberId)).willReturn(false); + given(messageReadStatusRepository.existsDirectUnreadMessagesByMemberId(memberId)).willReturn(true); + + // when + var result = chatQueryService.getUnreadStatus(memberId); + + // then + assertThat(result.hasUnread()).isTrue(); + assertThat(result.hasPartyUnread()).isFalse(); + assertThat(result.hasDirectUnread()).isTrue(); + } } @Nested @@ -142,7 +205,8 @@ void unreadCount_usesAllUnreadMessages_whenLastReadMessageIdIsNull() { .willReturn(chatRooms); given(chatRoomMemberRepository.findByChatRoomIdAndMemberId(roomId, memberId)).willReturn(Optional.of(membership)); given(chatRoomMemberRepository.countByChatRoomId(roomId)).willReturn(1); - given(messageReadStatusRepository.countAllUnreadMessages(roomId, memberId)).willReturn(4); + given(messageReadStatusRepository.countUnreadMessagesByChatRooms(memberId, List.of(roomId))) + .willReturn(List.of(new ChatRoomUnreadCountDTO(roomId, 4L))); // when PartyChatRoomDTO.Response result = chatQueryService.getPartyChatRooms(memberId, 0, 10); @@ -158,8 +222,7 @@ void unreadCount_usesAllUnreadMessages_whenLastReadMessageIdIsNull() { assertThat(roomInfo.unreadCount()).isEqualTo(4); assertThat(roomInfo.partyImgUrl()).isNull(); assertThat(roomInfo.lastMessage()).isNull(); - verify(messageReadStatusRepository).countAllUnreadMessages(roomId, memberId); - verify(messageReadStatusRepository, never()).countUnreadMessagesAfter(anyLong(), anyLong(), anyLong()); + verify(messageReadStatusRepository).countUnreadMessagesByChatRooms(memberId, List.of(roomId)); } @Test @@ -195,7 +258,8 @@ void mapsLastMessageAndPartyImage_andUsesUnreadAfter_whenLastReadMessageExists() .willReturn(chatRooms); given(chatRoomMemberRepository.findByChatRoomIdAndMemberId(roomId, memberId)).willReturn(Optional.of(membership)); given(chatRoomMemberRepository.countByChatRoomId(roomId)).willReturn(3); - given(messageReadStatusRepository.countUnreadMessagesAfter(roomId, memberId, 30L)).willReturn(2); + given(messageReadStatusRepository.countUnreadMessagesByChatRooms(memberId, List.of(roomId))) + .willReturn(List.of(new ChatRoomUnreadCountDTO(roomId, 2L))); given(chatRoomListCacheService.getLastMessage(roomId)).willReturn(lastMessage); given(fileService.getUrlFromKey("party/image.png")).willReturn("https://cdn.example.com/party/image.png"); @@ -218,8 +282,7 @@ void mapsLastMessageAndPartyImage_andUsesUnreadAfter_whenLastReadMessageExists() assertThat(roomInfo.lastMessage().timestamp()).isEqualTo(sentAt); assertThat(roomInfo.lastMessage().messageType()).isEqualTo("TEXT"); - verify(messageReadStatusRepository).countUnreadMessagesAfter(roomId, memberId, 30L); - verify(messageReadStatusRepository, never()).countAllUnreadMessages(roomId, memberId); + verify(messageReadStatusRepository).countUnreadMessagesByChatRooms(memberId, List.of(roomId)); verify(chatRoomListCacheService).getLastMessage(roomId); verify(fileService).getUrlFromKey("party/image.png"); } @@ -255,8 +318,6 @@ void preservesLatestMessageFirstOrder() { given(chatRoomMemberRepository.findByChatRoomIdAndMemberId(12L, memberId)).willReturn(Optional.of(olderMembership)); given(chatRoomMemberRepository.countByChatRoomId(11L)).willReturn(2); given(chatRoomMemberRepository.countByChatRoomId(12L)).willReturn(2); - given(messageReadStatusRepository.countAllUnreadMessages(11L, memberId)).willReturn(0); - given(messageReadStatusRepository.countAllUnreadMessages(12L, memberId)).willReturn(0); given(chatRoomListCacheService.getLastMessage(11L)).willReturn( LastMessageCacheDTO.builder().content("가장 최근 메시지").timestamp(LocalDateTime.of(2026, 4, 1, 20, 0)).messageType("TEXT").build()); given(chatRoomListCacheService.getLastMessage(12L)).willReturn( @@ -360,7 +421,6 @@ void returnsOnlyMatchedRoom_whenRepositoryAlreadyFilteredResults() { .willReturn(searchResult); given(chatRoomMemberRepository.findByChatRoomIdAndMemberId(roomId, memberId)).willReturn(Optional.of(membership)); given(chatRoomMemberRepository.countByChatRoomId(roomId)).willReturn(1); - given(messageReadStatusRepository.countAllUnreadMessages(roomId, memberId)).willReturn(0); // when PartyChatRoomDTO.Response result = chatQueryService.searchPartyChatRoomsByName(memberId, name, 0, 10); @@ -410,8 +470,6 @@ void preservesRepositoryOrder() { given(chatRoomMemberRepository.findByChatRoomIdAndMemberId(32L, memberId)).willReturn(Optional.of(olderMembership)); given(chatRoomMemberRepository.countByChatRoomId(31L)).willReturn(2); given(chatRoomMemberRepository.countByChatRoomId(32L)).willReturn(2); - given(messageReadStatusRepository.countAllUnreadMessages(31L, memberId)).willReturn(0); - given(messageReadStatusRepository.countAllUnreadMessages(32L, memberId)).willReturn(0); // when PartyChatRoomDTO.Response result = chatQueryService.searchPartyChatRoomsByName(memberId, name, 0, 10); @@ -512,7 +570,8 @@ void usesAllUnreadCountAndMyDisplayName_whenLastReadMessageIdIsNull() { given(chatRoomRepository.findDirectChatRoomByMemberIdOrderByLastMsgIdDesc(memberId, PageRequest.of(0, 10))) .willReturn(chatRooms); given(chatRoomMemberRepository.findByChatRoomIdAndMemberId(roomId, memberId)).willReturn(Optional.of(myMembership)); - given(messageReadStatusRepository.countAllUnreadMessages(roomId, memberId)).willReturn(3); + given(messageReadStatusRepository.countUnreadMessagesByChatRooms(memberId, List.of(roomId))) + .willReturn(List.of(new ChatRoomUnreadCountDTO(roomId, 3L))); // when DirectChatRoomDTO.Response result = chatQueryService.getDirectChatRooms(memberId, 0, 10); @@ -528,8 +587,7 @@ void usesAllUnreadCountAndMyDisplayName_whenLastReadMessageIdIsNull() { assertThat(roomInfo.unreadCount()).isEqualTo(3); assertThat(roomInfo.lastMessage()).isNull(); - verify(messageReadStatusRepository).countAllUnreadMessages(roomId, memberId); - verify(messageReadStatusRepository, never()).countUnreadMessagesAfter(anyLong(), anyLong(), anyLong()); + verify(messageReadStatusRepository).countUnreadMessagesByChatRooms(memberId, List.of(roomId)); verify(fileService, never()).getUrlFromKey(any()); } @@ -573,7 +631,8 @@ void mapsProfileImageLastMessageAndUnreadAfter_whenLastReadMessageExists() { given(chatRoomRepository.findDirectChatRoomByMemberIdOrderByLastMsgIdDesc(memberId, PageRequest.of(0, 5))) .willReturn(chatRooms); given(chatRoomMemberRepository.findByChatRoomIdAndMemberId(roomId, memberId)).willReturn(Optional.of(myMembership)); - given(messageReadStatusRepository.countUnreadMessagesAfter(roomId, memberId, 30L)).willReturn(2); + given(messageReadStatusRepository.countUnreadMessagesByChatRooms(memberId, List.of(roomId))) + .willReturn(List.of(new ChatRoomUnreadCountDTO(roomId, 2L))); given(chatRoomListCacheService.getLastMessage(roomId)).willReturn(lastMessage); given(fileService.getUrlFromKey("member/profile.png")).willReturn("https://cdn.example.com/member/profile.png"); @@ -594,8 +653,7 @@ void mapsProfileImageLastMessageAndUnreadAfter_whenLastReadMessageExists() { assertThat(roomInfo.lastMessage().timestamp()).isEqualTo(sentAt); assertThat(roomInfo.lastMessage().messageType()).isEqualTo("TEXT"); - verify(messageReadStatusRepository).countUnreadMessagesAfter(roomId, memberId, 30L); - verify(messageReadStatusRepository, never()).countAllUnreadMessages(roomId, memberId); + verify(messageReadStatusRepository).countUnreadMessagesByChatRooms(memberId, List.of(roomId)); verify(chatRoomListCacheService).getLastMessage(roomId); verify(fileService).getUrlFromKey("member/profile.png"); } @@ -631,7 +689,6 @@ void marksCounterPartWithdrawn_whenMemberInactive() { given(chatRoomRepository.findDirectChatRoomByMemberIdOrderByLastMsgIdDesc(memberId, PageRequest.of(0, 10))) .willReturn(chatRooms); given(chatRoomMemberRepository.findByChatRoomIdAndMemberId(roomId, memberId)).willReturn(Optional.of(myMembership)); - given(messageReadStatusRepository.countAllUnreadMessages(roomId, memberId)).willReturn(0); // when DirectChatRoomDTO.Response result = chatQueryService.getDirectChatRooms(memberId, 0, 10); @@ -670,7 +727,6 @@ void mapsUnknownUser_whenCounterPartMemberIsNull() { given(chatRoomRepository.findDirectChatRoomByMemberIdOrderByLastMsgIdDesc(memberId, PageRequest.of(0, 10))) .willReturn(chatRooms); given(chatRoomMemberRepository.findByChatRoomIdAndMemberId(roomId, memberId)).willReturn(Optional.of(myMembership)); - given(messageReadStatusRepository.countAllUnreadMessages(roomId, memberId)).willReturn(0); // when DirectChatRoomDTO.Response result = chatQueryService.getDirectChatRooms(memberId, 0, 10); @@ -720,8 +776,6 @@ void preservesRepositoryOrder() { .willReturn(orderedChatRooms); given(chatRoomMemberRepository.findByChatRoomIdAndMemberId(51L, memberId)).willReturn(Optional.of(newerMyMembership)); given(chatRoomMemberRepository.findByChatRoomIdAndMemberId(52L, memberId)).willReturn(Optional.of(olderMyMembership)); - given(messageReadStatusRepository.countAllUnreadMessages(51L, memberId)).willReturn(0); - given(messageReadStatusRepository.countAllUnreadMessages(52L, memberId)).willReturn(0); // when DirectChatRoomDTO.Response result = chatQueryService.getDirectChatRooms(memberId, 0, 10); @@ -806,8 +860,8 @@ void mapsMatchedRoomUsingCurrentMembershipDisplayName() { .willReturn(searchResult); given(chatRoomMemberRepository.findByChatRoomIdAndMemberId(roomId, memberId)) .willReturn(Optional.of(myMembership)); - given(messageReadStatusRepository.countUnreadMessagesAfter(roomId, memberId, 40L)) - .willReturn(2); + given(messageReadStatusRepository.countUnreadMessagesByChatRooms(memberId, List.of(roomId))) + .willReturn(List.of(new ChatRoomUnreadCountDTO(roomId, 2L))); given(chatRoomListCacheService.getLastMessage(roomId)).willReturn(lastMessage); // when DirectChatRoomDTO.Response result = chatQueryService.searchDirectChatRoomsByName(memberId, name, 0, 5); @@ -828,7 +882,7 @@ void mapsMatchedRoomUsingCurrentMembershipDisplayName() { assertThat(roomInfo.lastMessage().messageType()).isEqualTo("TEXT"); verify(chatRoomRepository).searchDirectChatRoomsByName(memberId, name, PageRequest.of(0, 5)); - verify(messageReadStatusRepository).countUnreadMessagesAfter(roomId, memberId, 40L); + verify(messageReadStatusRepository).countUnreadMessagesByChatRooms(memberId, List.of(roomId)); verify(fileService, never()).getUrlFromKey("member/search-profile.png"); verify(chatRoomListCacheService).getLastMessage(roomId); } @@ -877,8 +931,6 @@ void preservesRepositoryOrder() { .willReturn(Optional.of(newerMyMembership)); given(chatRoomMemberRepository.findByChatRoomIdAndMemberId(72L, memberId)) .willReturn(Optional.of(olderMyMembership)); - given(messageReadStatusRepository.countAllUnreadMessages(71L, memberId)).willReturn(0); - given(messageReadStatusRepository.countAllUnreadMessages(72L, memberId)).willReturn(0); // when DirectChatRoomDTO.Response result = chatQueryService.searchDirectChatRoomsByName(memberId, name, 1, 2); diff --git a/src/test/java/umc/cockple/demo/domain/chat/service/ChatSendServiceTest.java b/src/test/java/umc/cockple/demo/domain/chat/service/ChatSendServiceTest.java deleted file mode 100644 index 54a81d55f..000000000 --- a/src/test/java/umc/cockple/demo/domain/chat/service/ChatSendServiceTest.java +++ /dev/null @@ -1,154 +0,0 @@ -package umc.cockple.demo.domain.chat.service; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.test.util.ReflectionTestUtils; -import umc.cockple.demo.domain.chat.converter.ChatConverter; -import umc.cockple.demo.domain.chat.domain.ChatMessage; -import umc.cockple.demo.domain.chat.domain.ChatRoom; -import umc.cockple.demo.domain.chat.domain.ChatRoomMember; -import umc.cockple.demo.domain.chat.dto.WebSocketMessageDTO; -import umc.cockple.demo.domain.chat.enums.MessageType; -import umc.cockple.demo.domain.chat.events.ChatRoomListUpdateEvent; -import umc.cockple.demo.domain.chat.repository.ChatMessageRepository; -import umc.cockple.demo.domain.chat.repository.ChatRoomMemberRepository; -import umc.cockple.demo.domain.chat.repository.ChatRoomRepository; -import umc.cockple.demo.domain.chat.repository.MessageReadStatusRepository; -import umc.cockple.demo.domain.chat.service.websocket.ChatReadService; -import umc.cockple.demo.domain.chat.service.websocket.ChatSendService; -import umc.cockple.demo.domain.chat.service.websocket.MessageReadCreationService; -import umc.cockple.demo.domain.chat.service.websocket.SubscriptionService; -import umc.cockple.demo.domain.member.domain.Member; -import umc.cockple.demo.domain.member.repository.MemberRepository; -import umc.cockple.demo.domain.party.domain.Party; -import umc.cockple.demo.support.fixture.ChatFixture; -import umc.cockple.demo.support.fixture.MemberFixture; -import umc.cockple.demo.support.fixture.PartyFixture; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.isNull; -import static org.mockito.BDDMockito.given; -import static org.mockito.BDDMockito.then; - -@ExtendWith(MockitoExtension.class) -@DisplayName("ChatSendService") -class ChatSendServiceTest { - - @Mock private ChatRoomRepository chatRoomRepository; - @Mock private MemberRepository memberRepository; - @Mock private ChatMessageRepository chatMessageRepository; - @Mock private ChatRoomMemberRepository chatRoomMemberRepository; - @Mock private MessageReadStatusRepository messageReadStatusRepository; - @Mock private SubscriptionService subscriptionService; - @Mock private MessageReadCreationService messageReadCreationService; - @Mock private ChatProcessor chatProcessor; - @Mock private ChatReadService chatReadService; - @Mock private ApplicationEventPublisher eventPublisher; - - private ChatSendService chatSendService; - private ChatConverter chatConverter; - - @BeforeEach - void setUp() { - chatConverter = new ChatConverter(); - chatSendService = new ChatSendService( - chatRoomRepository, - memberRepository, - chatMessageRepository, - chatRoomMemberRepository, - messageReadStatusRepository, - subscriptionService, - messageReadCreationService, - chatProcessor, - chatConverter, - chatReadService, - eventPublisher - ); - } - - @Test - @DisplayName("시스템 메시지를 저장하면 읽음 상태, 브로드캐스트, 채팅방 목록 업데이트를 함께 처리한다") - void sendSystemMessage_publishesReadStatusBroadcastAndListUpdate() { - // given - Long partyId = 10L; - Long roomId = 20L; - LocalDateTime sentAt = LocalDateTime.of(2026, 5, 21, 13, 15); - String content = "홍길동님이 모임에 참여하셨습니다."; - - Member memberA = MemberFixture.createMemberWithName("홍길동", "길동", umc.cockple.demo.global.enums.Gender.MALE, umc.cockple.demo.global.enums.Level.A, 1001L); - ReflectionTestUtils.setField(memberA, "id", 101L); - Member memberB = MemberFixture.createMemberWithName("김철수", "철수", umc.cockple.demo.global.enums.Gender.MALE, umc.cockple.demo.global.enums.Level.B, 1002L); - ReflectionTestUtils.setField(memberB, "id", 102L); - - Party party = PartyFixture.createParty("배드민턴 모임", 101L, PartyFixture.createPartyAddr("서울", "강남구")); - ReflectionTestUtils.setField(party, "id", partyId); - - ChatRoom chatRoom = ChatFixture.createPartyChatRoom(party); - ReflectionTestUtils.setField(chatRoom, "id", roomId); - - ChatRoomMember memberOne = ChatFixture.createJoinedMember(chatRoom, memberA); - ChatRoomMember memberTwo = ChatFixture.createJoinedMember(chatRoom, memberB); - - given(chatRoomRepository.findByPartyId(partyId)).willReturn(Optional.of(chatRoom)); - given(chatMessageRepository.save(any(ChatMessage.class))).willAnswer(invocation -> { - ChatMessage savedMessage = invocation.getArgument(0); - ReflectionTestUtils.setField(savedMessage, "id", 300L); - ReflectionTestUtils.setField(savedMessage, "createdAt", sentAt); - return savedMessage; - }); - given(subscriptionService.getActiveSubscribers(roomId)).willReturn(List.of(101L)); - given(chatReadService.subscribersToReadStatus(eq(roomId), anyLong(), eq(List.of(101L)), isNull())).willReturn(1); - given(chatRoomMemberRepository.findMemberIdsByChatRoomId(roomId)).willReturn(List.of(101L, 102L)); - given(chatRoomMemberRepository.findByChatRoomIdAndMemberId(roomId, 101L)).willReturn(Optional.of(memberOne)); - given(chatRoomMemberRepository.findByChatRoomIdAndMemberId(roomId, 102L)).willReturn(Optional.of(memberTwo)); - given(messageReadStatusRepository.countAllUnreadMessages(roomId, 101L)).willReturn(0); - given(messageReadStatusRepository.countAllUnreadMessages(roomId, 102L)).willReturn(1); - - // when - chatSendService.sendSystemMessage(partyId, content); - - // then - ArgumentCaptor savedMessageCaptor = ArgumentCaptor.forClass(ChatMessage.class); - then(chatMessageRepository).should().save(savedMessageCaptor.capture()); - ChatMessage savedMessage = savedMessageCaptor.getValue(); - assertThat(savedMessage.getType()).isEqualTo(MessageType.SYSTEM); - assertThat(savedMessage.getSender()).isNull(); - assertThat(savedMessage.getContent()).isEqualTo(content); - - then(messageReadCreationService).should().createReadStatusForNewMessage(savedMessage, null); - then(chatReadService).should().subscribersToReadStatus(roomId, 300L, List.of(101L), null); - - ArgumentCaptor messageResponseCaptor = - ArgumentCaptor.forClass(WebSocketMessageDTO.MessageResponse.class); - then(subscriptionService).should().broadcastSystemMessage(eq(roomId), messageResponseCaptor.capture()); - WebSocketMessageDTO.MessageResponse response = messageResponseCaptor.getValue(); - assertThat(response.chatRoomId()).isEqualTo(roomId); - assertThat(response.messageId()).isEqualTo(300L); - assertThat(response.content()).isEqualTo(content); - assertThat(response.messageType()).isEqualTo(MessageType.SYSTEM); - assertThat(response.senderId()).isNull(); - assertThat(response.senderName()).isEqualTo("시스템"); - - ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(ChatRoomListUpdateEvent.class); - then(eventPublisher).should().publishEvent(eventCaptor.capture()); - ChatRoomListUpdateEvent event = eventCaptor.getValue(); - assertThat(event.chatRoomId()).isEqualTo(roomId); - assertThat(event.content()).isEqualTo(content); - assertThat(event.timestamp()).isEqualTo(sentAt); - assertThat(event.messageType()).isEqualTo(MessageType.SYSTEM.name()); - assertThat(event.memberUnreadCounts()).containsEntry(101L, 0).containsEntry(102L, 1); - } -} diff --git a/src/test/java/umc/cockple/demo/domain/chat/service/ChatUnreadQueryServiceTest.java b/src/test/java/umc/cockple/demo/domain/chat/service/ChatUnreadQueryServiceTest.java new file mode 100644 index 000000000..6a33d9131 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/chat/service/ChatUnreadQueryServiceTest.java @@ -0,0 +1,167 @@ +package umc.cockple.demo.domain.chat.service; + +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.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import umc.cockple.demo.domain.chat.repository.projection.ChatMemberUnreadCountDTO; +import umc.cockple.demo.domain.chat.repository.projection.ChatRoomUnreadCountDTO; +import umc.cockple.demo.domain.chat.repository.MessageReadStatusRepository; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ChatUnreadQueryService") +class ChatUnreadQueryServiceTest { + + @Mock private MessageReadStatusRepository messageReadStatusRepository; + + private ChatUnreadQueryService chatUnreadQueryService; + + @BeforeEach + void setUp() { + chatUnreadQueryService = new ChatUnreadQueryService(messageReadStatusRepository); + } + + @Nested + @DisplayName("batch countUnreadMessages") + class BatchCountUnreadMessages { + + @Test + @DisplayName("채팅방별 안읽음 수를 조회하고 결과가 없는 채팅방은 0으로 채운다") + void countUnreadMessagesByChatRooms_fillsMissingRoomsWithZero() { + // given + Long memberId = 10L; + List chatRoomIds = List.of(1L, 2L, 3L); + given(messageReadStatusRepository.countUnreadMessagesByChatRooms(memberId, chatRoomIds)) + .willReturn(List.of( + new ChatRoomUnreadCountDTO(1L, 4L), + new ChatRoomUnreadCountDTO(3L, 2L) + )); + + // when + Map result = chatUnreadQueryService.countUnreadMessagesByChatRooms(memberId, chatRoomIds); + + // then + assertThat(result).containsEntry(1L, 4) + .containsEntry(2L, 0) + .containsEntry(3L, 2); + } + + @Test + @DisplayName("입력 목록이 비어 있으면 repository를 호출하지 않고 빈 Map을 반환한다") + void countUnreadMessagesByChatRooms_returnsEmptyMap_whenChatRoomIdsAreEmpty() { + // when + Map result = chatUnreadQueryService.countUnreadMessagesByChatRooms(10L, List.of()); + + // then + assertThat(result).isEmpty(); + verify(messageReadStatusRepository, never()).countUnreadMessagesByChatRooms(anyLong(), anyList()); + } + + @Test + @DisplayName("멤버별 안읽음 수를 조회하고 결과가 없는 멤버는 0으로 채운다") + void countUnreadMessagesByMembers_fillsMissingMembersWithZero() { + // given + Long chatRoomId = 1L; + List memberIds = List.of(10L, 20L, 30L); + given(messageReadStatusRepository.countUnreadMessagesByMembers(chatRoomId, memberIds)) + .willReturn(List.of(new ChatMemberUnreadCountDTO(20L, 5L))); + + // when + Map result = chatUnreadQueryService.countUnreadMessagesByMembers(chatRoomId, memberIds); + + // then + assertThat(result).containsEntry(10L, 0) + .containsEntry(20L, 5) + .containsEntry(30L, 0); + } + } + + @Nested + @DisplayName("타입별 안읽음 여부") + class HasUnreadMessagesByType { + + @Test + @DisplayName("모임 채팅 안읽음 여부는 repository exists 쿼리를 사용한다") + void hasPartyUnreadMessages_usesRepositoryExists() { + // given + Long memberId = 10L; + given(messageReadStatusRepository.existsPartyUnreadMessagesByMemberId(memberId)).willReturn(true); + + // when + boolean result = chatUnreadQueryService.hasPartyUnreadMessages(memberId); + + // then + assertThat(result).isTrue(); + verify(messageReadStatusRepository).existsPartyUnreadMessagesByMemberId(memberId); + } + + @Test + @DisplayName("개인 채팅 안읽음 여부는 repository exists 쿼리를 사용한다") + void hasDirectUnreadMessages_usesRepositoryExists() { + // given + Long memberId = 10L; + given(messageReadStatusRepository.existsDirectUnreadMessagesByMemberId(memberId)).willReturn(false); + + // when + boolean result = chatUnreadQueryService.hasDirectUnreadMessages(memberId); + + // then + assertThat(result).isFalse(); + verify(messageReadStatusRepository).existsDirectUnreadMessagesByMemberId(memberId); + } + + @Test + @DisplayName("멤버 목록의 모임/개인 채팅 안읽음 여부를 batch 조회한다") + void findUnreadStatusesByMembers_usesBatchQueries() { + // given + List memberIds = List.of(10L, 20L, 30L); + given(messageReadStatusRepository.findMemberIdsWithPartyUnreadMessages(memberIds)) + .willReturn(List.of(10L)); + given(messageReadStatusRepository.findMemberIdsWithDirectUnreadMessages(memberIds)) + .willReturn(List.of(20L)); + + // when + Map result = + chatUnreadQueryService.findUnreadStatusesByMembers(memberIds); + + // then + assertThat(result).containsOnlyKeys(10L, 20L, 30L); + assertThat(result.get(10L).hasUnread()).isTrue(); + assertThat(result.get(10L).hasPartyUnread()).isTrue(); + assertThat(result.get(10L).hasDirectUnread()).isFalse(); + assertThat(result.get(20L).hasUnread()).isTrue(); + assertThat(result.get(20L).hasPartyUnread()).isFalse(); + assertThat(result.get(20L).hasDirectUnread()).isTrue(); + assertThat(result.get(30L).hasUnread()).isFalse(); + assertThat(result.get(30L).hasPartyUnread()).isFalse(); + assertThat(result.get(30L).hasDirectUnread()).isFalse(); + } + + @Test + @DisplayName("멤버 목록이 비어 있으면 batch repository를 호출하지 않는다") + void findUnreadStatusesByMembers_returnsEmptyMap_whenMemberIdsAreEmpty() { + // when + Map result = + chatUnreadQueryService.findUnreadStatusesByMembers(List.of()); + + // then + assertThat(result).isEmpty(); + verify(messageReadStatusRepository, never()).findMemberIdsWithPartyUnreadMessages(anyList()); + verify(messageReadStatusRepository, never()).findMemberIdsWithDirectUnreadMessages(anyList()); + } + } + +} diff --git a/src/test/java/umc/cockple/demo/domain/chat/service/listener/ChatRoomListUpdateEventListenerTest.java b/src/test/java/umc/cockple/demo/domain/chat/service/listener/ChatRoomListUpdateEventListenerTest.java new file mode 100644 index 000000000..ab064ba34 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/chat/service/listener/ChatRoomListUpdateEventListenerTest.java @@ -0,0 +1,66 @@ +package umc.cockple.demo.domain.chat.service.listener; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import umc.cockple.demo.domain.chat.events.ChatRoomListUpdateEvent; +import umc.cockple.demo.domain.chat.service.websocket.ChatRoomListCacheService; +import umc.cockple.demo.domain.chat.service.websocket.broadcast.ChatRoomListUpdateBroadcaster; +import umc.cockple.demo.domain.chat.service.websocket.broadcast.ChatRoomListUpdateData; + +import java.time.LocalDateTime; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.then; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ChatRoomListUpdateEventListener") +class ChatRoomListUpdateEventListenerTest { + + @Mock private ChatRoomListCacheService chatRoomListCacheService; + @Mock private ChatRoomListUpdateBroadcaster chatRoomListUpdateBroadcaster; + + private ChatRoomListUpdateEventListener listener; + + @BeforeEach + void setUp() { + listener = new ChatRoomListUpdateEventListener(chatRoomListCacheService, chatRoomListUpdateBroadcaster); + } + + @Test + @DisplayName("채팅방 목록 업데이트 이벤트는 캐시를 비우고 멤버별 업데이트를 브로드캐스터에 위임한다") + void handleChatRoomListUpdate_evictsCacheAndDelegatesBroadcast() { + // given + Long chatRoomId = 1L; + LocalDateTime timestamp = LocalDateTime.of(2026, 5, 21, 13, 15); + ChatRoomListUpdateEvent event = ChatRoomListUpdateEvent.create( + chatRoomId, + "hello", + timestamp, + "TEXT", + Map.of(10L, 2) + ); + + // when + listener.handleChatRoomListUpdate(event); + + // then + then(chatRoomListCacheService).should().evictLastMessage(chatRoomId); + + @SuppressWarnings("unchecked") + ArgumentCaptor> updateCaptor = + ArgumentCaptor.forClass((Class) Map.class); + then(chatRoomListUpdateBroadcaster).should().broadcast(org.mockito.ArgumentMatchers.eq(chatRoomId), updateCaptor.capture()); + + ChatRoomListUpdateData updateData = updateCaptor.getValue().get(10L); + assertThat(updateData.unreadCount()).isEqualTo(2); + assertThat(updateData.lastMessage().content()).isEqualTo("hello"); + assertThat(updateData.lastMessage().timestamp()).isEqualTo(timestamp); + assertThat(updateData.lastMessage().messageType()).isEqualTo("TEXT"); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/chat/events/ChatRoomRedisCleanupListenerTest.java b/src/test/java/umc/cockple/demo/domain/chat/service/listener/ChatRoomRedisCleanupListenerTest.java similarity index 75% rename from src/test/java/umc/cockple/demo/domain/chat/events/ChatRoomRedisCleanupListenerTest.java rename to src/test/java/umc/cockple/demo/domain/chat/service/listener/ChatRoomRedisCleanupListenerTest.java index b0ae82c4c..98da44629 100644 --- a/src/test/java/umc/cockple/demo/domain/chat/events/ChatRoomRedisCleanupListenerTest.java +++ b/src/test/java/umc/cockple/demo/domain/chat/service/listener/ChatRoomRedisCleanupListenerTest.java @@ -1,4 +1,4 @@ -package umc.cockple.demo.domain.chat.events; +package umc.cockple.demo.domain.chat.service.listener; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -9,9 +9,10 @@ import org.springframework.scheduling.annotation.Async; import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; -import umc.cockple.demo.domain.chat.service.websocket.ChatListSubscriptionService; +import umc.cockple.demo.domain.chat.events.ChatRoomRedisCleanupEvent; +import umc.cockple.demo.domain.chat.repository.redis.ChatListSubscriptionStore; import umc.cockple.demo.domain.chat.service.websocket.ChatRoomListCacheService; -import umc.cockple.demo.domain.chat.service.websocket.RedisSubscriptionService; +import umc.cockple.demo.domain.chat.repository.redis.ChatRoomSubscriptionStore; import java.lang.reflect.Method; @@ -30,9 +31,9 @@ class ChatRoomRedisCleanupListenerTest { @Mock private ChatRoomListCacheService chatRoomListCacheService; @Mock - private RedisSubscriptionService redisSubscriptionService; + private ChatRoomSubscriptionStore chatRoomSubscriptionStore; @Mock - private ChatListSubscriptionService chatListSubscriptionService; + private ChatListSubscriptionStore chatListSubscriptionStore; @Test @DisplayName("채팅방 Redis 정리 이벤트는 커밋 이후 비동기로 처리되도록 설정한다") @@ -57,10 +58,10 @@ void handleChatRoomRedisCleanup_clearsCacheAndSubscriptionKeys() { listener.handleChatRoomRedisCleanup(event); - var inOrder = inOrder(chatRoomListCacheService, redisSubscriptionService, chatListSubscriptionService); + var inOrder = inOrder(chatRoomListCacheService, chatRoomSubscriptionStore, chatListSubscriptionStore); inOrder.verify(chatRoomListCacheService).evictLastMessage(chatRoomId); - inOrder.verify(redisSubscriptionService).tryClearRoomSubscribers(chatRoomId); - inOrder.verify(chatListSubscriptionService).tryClearChatListSubscribers(chatRoomId); + inOrder.verify(chatRoomSubscriptionStore).tryClearRoomSubscribers(chatRoomId); + inOrder.verify(chatListSubscriptionStore).tryClearChatListSubscribers(chatRoomId); } @Test @@ -73,7 +74,7 @@ void handleChatRoomRedisCleanup_continuesWhenCacheEvictFails() { listener.handleChatRoomRedisCleanup(ChatRoomRedisCleanupEvent.of(chatRoomId)); - verify(redisSubscriptionService).tryClearRoomSubscribers(chatRoomId); - verify(chatListSubscriptionService).tryClearChatListSubscribers(chatRoomId); + verify(chatRoomSubscriptionStore).tryClearRoomSubscribers(chatRoomId); + verify(chatListSubscriptionStore).tryClearChatListSubscribers(chatRoomId); } } diff --git a/src/test/java/umc/cockple/demo/domain/chat/service/listener/ChatSubscriptionEventListenerTest.java b/src/test/java/umc/cockple/demo/domain/chat/service/listener/ChatSubscriptionEventListenerTest.java new file mode 100644 index 000000000..229b91df6 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/chat/service/listener/ChatSubscriptionEventListenerTest.java @@ -0,0 +1,57 @@ +package umc.cockple.demo.domain.chat.service.listener; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import umc.cockple.demo.domain.chat.events.ChatListSubscriptionEvent; +import umc.cockple.demo.domain.chat.events.ChatRoomSubscriptionEvent; +import umc.cockple.demo.domain.chat.repository.redis.ChatListSubscriptionStore; +import umc.cockple.demo.domain.chat.service.websocket.subscription.ChatRoomSubscriptionService; + +import java.util.List; + +import static org.mockito.BDDMockito.then; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ChatSubscriptionEventListener") +class ChatSubscriptionEventListenerTest { + + @Mock private ChatRoomSubscriptionService chatRoomSubscriptionService; + @Mock private ChatListSubscriptionStore chatListSubscriptionStore; + + private ChatSubscriptionEventListener listener; + + @BeforeEach + void setUp() { + listener = new ChatSubscriptionEventListener(chatRoomSubscriptionService, chatListSubscriptionStore); + } + + @Test + @DisplayName("채팅방 구독 이벤트는 채팅방 구독 서비스에 위임한다") + void handleChatRoomSubscription_delegatesSubscribe() { + // given + ChatRoomSubscriptionEvent event = ChatRoomSubscriptionEvent.subscribe(1L, 10L); + + // when + listener.handleChatRoomSubscription(event); + + // then + then(chatRoomSubscriptionService).should().subscribeToChatRoom(1L, 10L); + } + + @Test + @DisplayName("채팅방 목록 구독 이벤트는 Redis 구독 저장소에 위임한다") + void handleChatListSubscription_delegatesSubscribe() { + // given + ChatListSubscriptionEvent event = ChatListSubscriptionEvent.subscribe(10L, List.of(1L, 2L)); + + // when + listener.handleChatListSubscription(event); + + // then + then(chatListSubscriptionStore).should().subscribeToChatList(10L, List.of(1L, 2L)); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/chat/service/listener/ChatUnreadStatusUpdateEventFlowTest.java b/src/test/java/umc/cockple/demo/domain/chat/service/listener/ChatUnreadStatusUpdateEventFlowTest.java new file mode 100644 index 000000000..721d21368 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/chat/service/listener/ChatUnreadStatusUpdateEventFlowTest.java @@ -0,0 +1,180 @@ +package umc.cockple.demo.domain.chat.service.listener; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.core.task.SyncTaskExecutor; +import org.springframework.core.task.TaskExecutor; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.web.socket.WebSocketSession; +import umc.cockple.demo.domain.chat.domain.ChatMessage; +import umc.cockple.demo.domain.chat.domain.ChatRoom; +import umc.cockple.demo.domain.chat.domain.ChatRoomMember; +import umc.cockple.demo.domain.chat.domain.MessageReadStatus; +import umc.cockple.demo.domain.chat.presentation.websocket.session.WebSocketSessionRegistry; +import umc.cockple.demo.domain.chat.repository.ChatMessageRepository; +import umc.cockple.demo.domain.chat.repository.ChatRoomMemberRepository; +import umc.cockple.demo.domain.chat.repository.ChatRoomRepository; +import umc.cockple.demo.domain.chat.repository.MessageReadStatusRepository; +import umc.cockple.demo.domain.chat.service.websocket.send.support.ChatSendEventPublisher; +import umc.cockple.demo.domain.chat.service.websocket.session.ChatMessageSender; +import umc.cockple.demo.domain.chat.service.websocket.session.EncodedChatMessage; +import umc.cockple.demo.domain.chat.service.websocket.subscription.support.SubscribeReadStatusService; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.domain.party.domain.Party; +import umc.cockple.demo.domain.party.domain.PartyAddr; +import umc.cockple.demo.domain.party.repository.PartyAddrRepository; +import umc.cockple.demo.domain.party.repository.PartyRepository; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.support.IntegrationTestBase; +import umc.cockple.demo.support.fixture.ChatFixture; +import umc.cockple.demo.support.fixture.MemberFixture; +import umc.cockple.demo.support.fixture.PartyFixture; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.times; + +@DisplayName("ChatUnreadStatusUpdateEvent after-commit flow") +// 채팅 @Async 풀(chatExecutor)을 SyncTaskExecutor로 덮어써 동기 실행하려면 빈 오버라이드 허용 필요 +@TestPropertySource(properties = "spring.main.allow-bean-definition-overriding=true") +class ChatUnreadStatusUpdateEventFlowTest extends IntegrationTestBase { + + // handleChatUnreadStatusUpdate의 @Async("chatExecutor")를 동기로 실행해 테스트 타이밍 문제를 제거 + @TestConfiguration + static class SyncAsyncConfig { + + @Bean + public TaskExecutor chatExecutor() { + return new SyncTaskExecutor(); + } + } + + @Autowired private MemberRepository memberRepository; + @Autowired private PartyRepository partyRepository; + @Autowired private PartyAddrRepository partyAddrRepository; + @Autowired private ChatRoomRepository chatRoomRepository; + @Autowired private ChatRoomMemberRepository chatRoomMemberRepository; + @Autowired private ChatMessageRepository chatMessageRepository; + @Autowired private MessageReadStatusRepository messageReadStatusRepository; + @Autowired private WebSocketSessionRegistry sessionRegistry; + @Autowired private ChatSendEventPublisher chatSendEventPublisher; + @Autowired private SubscribeReadStatusService subscribeReadStatusService; + @Autowired private org.springframework.transaction.support.TransactionTemplate transactionTemplate; + + @MockitoBean private ChatMessageSender chatMessageSender; + + private final List registeredSessions = new ArrayList<>(); + + @AfterEach + void tearDown() { + registeredSessions.forEach(registered -> sessionRegistry.remove(registered.memberId(), registered.session())); + registeredSessions.clear(); + reset(chatMessageSender); + + messageReadStatusRepository.deleteAll(); + chatMessageRepository.deleteAll(); + chatRoomMemberRepository.deleteAll(); + chatRoomRepository.deleteAll(); + partyRepository.deleteAll(); + partyAddrRepository.deleteAll(); + memberRepository.deleteAll(); + } + + @Test + @DisplayName("메시지 전송 unread 이벤트는 커밋 후 unread-status WebSocket payload를 전송한다") + void sendUnreadStatusEvent_afterCommit_sendsUnreadStatusPayload() { + // given + Member sender = memberRepository.save(MemberFixture.createMember("발신자", Gender.MALE, Level.A, 1001L)); + Member receiver = memberRepository.save(MemberFixture.createMember("수신자", Gender.FEMALE, Level.B, 2002L)); + ChatRoom chatRoom = createPartyChatRoom(sender, receiver); + + ChatMessage unreadMessage = chatMessageRepository.save( + ChatFixture.createTextMessage(chatRoom, sender, "읽지 않은 모임 메시지")); + messageReadStatusRepository.save( + MessageReadStatus.createUnread(unreadMessage.getId(), receiver.getId(), chatRoom.getId())); + registerOpenSession(receiver.getId()); + + // when + transactionTemplate.execute(status -> { + chatSendEventPublisher.publishUnreadStatusUpdateEvent(chatRoom, sender.getId()); + return null; + }); + + // then + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(EncodedChatMessage.class); + then(chatMessageSender).should(times(1)).send(eq(receiver.getId()), messageCaptor.capture()); + + assertThat(messageCaptor.getValue().payload()) + .contains("\"type\":\"UNREAD_STATUS_UPDATE\"") + .contains("\"hasUnread\":true") + .contains("\"hasPartyUnread\":true") + .contains("\"hasDirectUnread\":false"); + } + + @Test + @DisplayName("구독 읽음 처리 unread 이벤트는 커밋 후 최신 unread-status WebSocket payload를 전송한다") + void subscribeUnreadStatusEvent_afterCommit_sendsUnreadStatusPayload() { + // given + Member subscriber = memberRepository.save(MemberFixture.createMember("구독자", Gender.MALE, Level.A, 1001L)); + Member sender = memberRepository.save(MemberFixture.createMember("상대방", Gender.FEMALE, Level.B, 2002L)); + ChatRoom chatRoom = chatRoomRepository.save(ChatFixture.createDirectChatRoom()); + chatRoomMemberRepository.save(ChatRoomMember.createJoined(chatRoom, subscriber, "상대방 채팅")); + chatRoomMemberRepository.save(ChatRoomMember.createJoined(chatRoom, sender, "구독자 채팅")); + + ChatMessage unreadMessage = chatMessageRepository.save( + ChatFixture.createTextMessage(chatRoom, sender, "구독 시 읽음 처리될 메시지")); + messageReadStatusRepository.save( + MessageReadStatus.createUnread(unreadMessage.getId(), subscriber.getId(), chatRoom.getId())); + registerOpenSession(subscriber.getId()); + + // when + transactionTemplate.execute(status -> { + subscribeReadStatusService.markUnreadMessagesAsReadOnSubscribe(chatRoom.getId(), subscriber.getId()); + return null; + }); + + // then + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(EncodedChatMessage.class); + then(chatMessageSender).should(times(1)).send(eq(subscriber.getId()), messageCaptor.capture()); + + assertThat(messageCaptor.getValue().payload()) + .contains("\"type\":\"UNREAD_STATUS_UPDATE\"") + .contains("\"hasUnread\":false") + .contains("\"hasPartyUnread\":false") + .contains("\"hasDirectUnread\":false"); + } + + private ChatRoom createPartyChatRoom(Member sender, Member receiver) { + PartyAddr partyAddr = partyAddrRepository.save(PartyFixture.createPartyAddr("서울특별시", "강남구")); + Party party = partyRepository.save(PartyFixture.createParty("배드민턴 모임", sender.getId(), partyAddr)); + ChatRoom chatRoom = chatRoomRepository.save(ChatFixture.createPartyChatRoom(party)); + chatRoomMemberRepository.save(ChatRoomMember.create(chatRoom, sender)); + chatRoomMemberRepository.save(ChatRoomMember.create(chatRoom, receiver)); + return chatRoom; + } + + private void registerOpenSession(Long memberId) { + WebSocketSession session = mock(WebSocketSession.class); + given(session.isOpen()).willReturn(true); + sessionRegistry.register(memberId, session); + registeredSessions.add(new RegisteredSession(memberId, session)); + } + + private record RegisteredSession(Long memberId, WebSocketSession session) { + } +} diff --git a/src/test/java/umc/cockple/demo/domain/chat/service/listener/ChatUnreadStatusUpdateEventListenerTest.java b/src/test/java/umc/cockple/demo/domain/chat/service/listener/ChatUnreadStatusUpdateEventListenerTest.java new file mode 100644 index 000000000..98c368f6f --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/chat/service/listener/ChatUnreadStatusUpdateEventListenerTest.java @@ -0,0 +1,130 @@ +package umc.cockple.demo.domain.chat.service.listener; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import umc.cockple.demo.domain.chat.dto.WebSocketMessageDTO; +import umc.cockple.demo.domain.chat.enums.WebSocketMessageType; +import umc.cockple.demo.domain.chat.events.ChatUnreadStatusUpdateEvent; +import umc.cockple.demo.domain.chat.service.ChatUnreadQueryService; +import umc.cockple.demo.domain.chat.service.websocket.session.ChatMessageEncoder; +import umc.cockple.demo.domain.chat.service.websocket.session.ChatMessageSender; +import umc.cockple.demo.domain.chat.service.websocket.session.ChatSessionRegistry; +import umc.cockple.demo.domain.chat.service.websocket.session.EncodedChatMessage; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.times; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ChatUnreadStatusUpdateEventListener") +class ChatUnreadStatusUpdateEventListenerTest { + + @Mock private ChatMessageSender chatMessageSender; + @Mock private ChatMessageEncoder chatMessageEncoder; + @Mock private ChatUnreadQueryService chatUnreadQueryService; + @Mock private ChatSessionRegistry chatSessionRegistry; + + @InjectMocks + private ChatUnreadStatusUpdateEventListener listener; + + @Test + @DisplayName("안읽음 상태 업데이트 이벤트는 REST 조회와 같은 기준으로 멤버별 payload를 전송한다") + void handleChatUnreadStatusUpdate_sendsMemberSpecificUnreadStatus() { + // given + Long partyUnreadMemberId = 101L; + Long directUnreadMemberId = 102L; + List targetMemberIds = List.of(partyUnreadMemberId, directUnreadMemberId); + Map unreadStatuses = new LinkedHashMap<>(); + unreadStatuses.put(partyUnreadMemberId, new ChatUnreadQueryService.UnreadStatus(true, false)); + unreadStatuses.put(directUnreadMemberId, new ChatUnreadQueryService.UnreadStatus(false, true)); + + given(chatSessionRegistry.findOpenMemberIds(targetMemberIds)).willReturn(targetMemberIds); + given(chatUnreadQueryService.findUnreadStatusesByMembers(targetMemberIds)).willReturn(unreadStatuses); + EncodedChatMessage partyEncodedMessage = new EncodedChatMessage("party-unread-json"); + EncodedChatMessage directEncodedMessage = new EncodedChatMessage("direct-unread-json"); + given(chatMessageEncoder.encode(any(WebSocketMessageDTO.UnreadStatusUpdateMessage.class))) + .willReturn(Optional.of(partyEncodedMessage), Optional.of(directEncodedMessage)); + + ChatUnreadStatusUpdateEvent event = + ChatUnreadStatusUpdateEvent.of(targetMemberIds); + + // when + listener.handleChatUnreadStatusUpdate(event); + + // then + ArgumentCaptor messageCaptor = + ArgumentCaptor.forClass(WebSocketMessageDTO.UnreadStatusUpdateMessage.class); + + then(chatMessageEncoder).should(times(2)).encode(messageCaptor.capture()); + then(chatMessageSender).should().send(partyUnreadMemberId, partyEncodedMessage); + then(chatMessageSender).should().send(directUnreadMemberId, directEncodedMessage); + + WebSocketMessageDTO.UnreadStatusUpdateMessage partyUnreadMessage = messageCaptor.getAllValues().get(0); + assertThat(partyUnreadMessage.type()).isEqualTo(WebSocketMessageType.UNREAD_STATUS_UPDATE); + assertThat(partyUnreadMessage.hasUnread()).isTrue(); + assertThat(partyUnreadMessage.hasPartyUnread()).isTrue(); + assertThat(partyUnreadMessage.hasDirectUnread()).isFalse(); + + WebSocketMessageDTO.UnreadStatusUpdateMessage directUnreadMessage = messageCaptor.getAllValues().get(1); + assertThat(directUnreadMessage.type()).isEqualTo(WebSocketMessageType.UNREAD_STATUS_UPDATE); + assertThat(directUnreadMessage.hasUnread()).isTrue(); + assertThat(directUnreadMessage.hasPartyUnread()).isFalse(); + assertThat(directUnreadMessage.hasDirectUnread()).isTrue(); + } + + @Test + @DisplayName("열린 WebSocket 세션이 있는 멤버만 안읽음 상태를 조회하고 전송한다") + void handleChatUnreadStatusUpdate_skipsMembersWithoutOpenSession() { + // given + Long openMemberId = 101L; + Long offlineMemberId = 102L; + List targetMemberIds = List.of(openMemberId, offlineMemberId); + + given(chatSessionRegistry.findOpenMemberIds(targetMemberIds)).willReturn(List.of(openMemberId)); + given(chatUnreadQueryService.findUnreadStatusesByMembers(List.of(openMemberId))) + .willReturn(Map.of(openMemberId, new ChatUnreadQueryService.UnreadStatus(false, true))); + EncodedChatMessage encodedMessage = new EncodedChatMessage("open-member-unread-json"); + given(chatMessageEncoder.encode(any(WebSocketMessageDTO.UnreadStatusUpdateMessage.class))) + .willReturn(Optional.of(encodedMessage)); + + ChatUnreadStatusUpdateEvent event = ChatUnreadStatusUpdateEvent.of(targetMemberIds); + + // when + listener.handleChatUnreadStatusUpdate(event); + + // then + then(chatUnreadQueryService).should().findUnreadStatusesByMembers(List.of(openMemberId)); + then(chatMessageSender).should(times(1)).send(eq(openMemberId), eq(encodedMessage)); + } + + @Test + @DisplayName("열린 WebSocket 세션이 없으면 안읽음 상태를 조회하지 않는다") + void handleChatUnreadStatusUpdate_returnsWhenNoOpenSession() { + // given + List targetMemberIds = List.of(101L, 102L); + given(chatSessionRegistry.findOpenMemberIds(targetMemberIds)).willReturn(List.of()); + + ChatUnreadStatusUpdateEvent event = ChatUnreadStatusUpdateEvent.of(targetMemberIds); + + // when + listener.handleChatUnreadStatusUpdate(event); + + // then + then(chatUnreadQueryService).shouldHaveNoInteractions(); + then(chatMessageEncoder).shouldHaveNoInteractions(); + then(chatMessageSender).shouldHaveNoInteractions(); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/chat/events/PartyDeletedChatCleanupListenerTest.java b/src/test/java/umc/cockple/demo/domain/chat/service/listener/PartyDeletedChatCleanupListenerTest.java similarity index 95% rename from src/test/java/umc/cockple/demo/domain/chat/events/PartyDeletedChatCleanupListenerTest.java rename to src/test/java/umc/cockple/demo/domain/chat/service/listener/PartyDeletedChatCleanupListenerTest.java index 3fc41f3d3..e7484889d 100644 --- a/src/test/java/umc/cockple/demo/domain/chat/events/PartyDeletedChatCleanupListenerTest.java +++ b/src/test/java/umc/cockple/demo/domain/chat/service/listener/PartyDeletedChatCleanupListenerTest.java @@ -1,4 +1,4 @@ -package umc.cockple.demo.domain.chat.events; +package umc.cockple.demo.domain.chat.service.listener; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/src/test/java/umc/cockple/demo/domain/chat/service/support/reader/ChatMemberReaderTest.java b/src/test/java/umc/cockple/demo/domain/chat/service/support/reader/ChatMemberReaderTest.java new file mode 100644 index 000000000..64615393d --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/chat/service/support/reader/ChatMemberReaderTest.java @@ -0,0 +1,64 @@ +package umc.cockple.demo.domain.chat.service.support.reader; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import umc.cockple.demo.domain.chat.exception.ChatErrorCode; +import umc.cockple.demo.domain.chat.exception.ChatException; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.support.fixture.MemberFixture; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ChatMemberReader") +class ChatMemberReaderTest { + + @Mock private MemberRepository memberRepository; + + private ChatMemberReader chatMemberReader; + + @BeforeEach + void setUp() { + chatMemberReader = new ChatMemberReader(memberRepository); + } + + @Test + @DisplayName("프로필 이미지와 함께 회원을 조회한다") + void readWithProfile_returnsMember() { + // given + Long memberId = 1L; + Member member = MemberFixture.createMemberWithName("홍길동", "길동", Gender.MALE, Level.A, 1001L); + given(memberRepository.findMemberWithProfileById(memberId)).willReturn(Optional.of(member)); + + // when + Member result = chatMemberReader.readWithProfile(memberId); + + // then + assertThat(result).isSameAs(member); + } + + @Test + @DisplayName("회원 조회 결과가 없으면 MEMBER_NOT_FOUND 예외를 던진다") + void readWithProfile_throwsWhenMemberNotFound() { + // given + Long memberId = 1L; + given(memberRepository.findMemberWithProfileById(memberId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> chatMemberReader.readWithProfile(memberId)) + .isInstanceOfSatisfying(ChatException.class, exception -> + assertThat(exception.getErrorReason().getCode()) + .isEqualTo(ChatErrorCode.MEMBER_NOT_FOUND.getCode())); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/chat/service/support/reader/ChatRoomReaderTest.java b/src/test/java/umc/cockple/demo/domain/chat/service/support/reader/ChatRoomReaderTest.java new file mode 100644 index 000000000..d7c5c24c0 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/chat/service/support/reader/ChatRoomReaderTest.java @@ -0,0 +1,94 @@ +package umc.cockple.demo.domain.chat.service.support.reader; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import umc.cockple.demo.domain.chat.domain.ChatRoom; +import umc.cockple.demo.domain.chat.exception.ChatErrorCode; +import umc.cockple.demo.domain.chat.exception.ChatException; +import umc.cockple.demo.domain.chat.repository.ChatRoomRepository; +import umc.cockple.demo.support.fixture.ChatFixture; +import umc.cockple.demo.support.fixture.PartyFixture; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ChatRoomReader") +class ChatRoomReaderTest { + + @Mock private ChatRoomRepository chatRoomRepository; + + private ChatRoomReader chatRoomReader; + + @BeforeEach + void setUp() { + chatRoomReader = new ChatRoomReader(chatRoomRepository); + } + + @Test + @DisplayName("채팅방 ID로 채팅방을 조회한다") + void read_returnsChatRoom() { + // given + Long chatRoomId = 1L; + ChatRoom chatRoom = ChatFixture.createDirectChatRoom(); + given(chatRoomRepository.findById(chatRoomId)).willReturn(Optional.of(chatRoom)); + + // when + ChatRoom result = chatRoomReader.read(chatRoomId); + + // then + assertThat(result).isSameAs(chatRoom); + } + + @Test + @DisplayName("채팅방 ID 조회 결과가 없으면 CHAT_ROOM_NOT_FOUND 예외를 던진다") + void read_throwsWhenChatRoomNotFound() { + // given + Long chatRoomId = 1L; + given(chatRoomRepository.findById(chatRoomId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> chatRoomReader.read(chatRoomId)) + .isInstanceOfSatisfying(ChatException.class, exception -> + assertThat(exception.getErrorReason().getCode()) + .isEqualTo(ChatErrorCode.CHAT_ROOM_NOT_FOUND.getCode())); + } + + @Test + @DisplayName("모임 ID로 채팅방을 조회한다") + void readByPartyId_returnsChatRoom() { + // given + Long partyId = 10L; + ChatRoom chatRoom = ChatFixture.createPartyChatRoom( + PartyFixture.createParty("모임", 1L, PartyFixture.createPartyAddr("서울", "강남구")) + ); + given(chatRoomRepository.findByPartyId(partyId)).willReturn(Optional.of(chatRoom)); + + // when + ChatRoom result = chatRoomReader.readByPartyId(partyId); + + // then + assertThat(result).isSameAs(chatRoom); + } + + @Test + @DisplayName("모임 ID 조회 결과가 없으면 CHAT_ROOM_NOT_FOUND 예외를 던진다") + void readByPartyId_throwsWhenChatRoomNotFound() { + // given + Long partyId = 10L; + given(chatRoomRepository.findByPartyId(partyId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> chatRoomReader.readByPartyId(partyId)) + .isInstanceOfSatisfying(ChatException.class, exception -> + assertThat(exception.getErrorReason().getCode()) + .isEqualTo(ChatErrorCode.CHAT_ROOM_NOT_FOUND.getCode())); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/chat/service/support/reader/ReadStatusReaderTest.java b/src/test/java/umc/cockple/demo/domain/chat/service/support/reader/ReadStatusReaderTest.java new file mode 100644 index 000000000..92dd35d17 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/chat/service/support/reader/ReadStatusReaderTest.java @@ -0,0 +1,139 @@ +package umc.cockple.demo.domain.chat.service.support.reader; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import umc.cockple.demo.domain.chat.repository.MessageReadStatusRepository; +import umc.cockple.demo.domain.chat.repository.projection.ChatMessageUnreadCountDTO; +import umc.cockple.demo.domain.chat.service.support.ReadStatusBatchSupport; + +import java.util.List; +import java.util.Map; +import java.util.stream.LongStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ReadStatusReader") +class ReadStatusReaderTest { + + @Mock private MessageReadStatusRepository messageReadStatusRepository; + + private ReadStatusReader readStatusReader; + + @BeforeEach + void setUp() { + readStatusReader = new ReadStatusReader(messageReadStatusRepository); + } + + @Test + @DisplayName("멤버의 unread 메시지 ID 목록을 조회한다") + void findUnreadMessageIds_returnsMessageIds() { + // given + Long chatRoomId = 10L; + Long memberId = 101L; + List messageIds = List.of(201L, 202L); + given(messageReadStatusRepository.findUnreadMessageIdsByMember(chatRoomId, memberId)) + .willReturn(messageIds); + + // when + List result = readStatusReader.findUnreadMessageIds(chatRoomId, memberId); + + // then + assertThat(result).isEqualTo(messageIds); + } + + @Test + @DisplayName("메시지별 unread 수를 sparse Map으로 변환한다") + void countUnreadByMessageIdsAsSparseMap_returnsSparseCountMap() { + // given + Long firstMessageId = 201L; + Long secondMessageId = 202L; + List messageIds = List.of(firstMessageId, secondMessageId); + given(messageReadStatusRepository.countUnreadByMessageIds(messageIds)) + .willReturn(List.of( + new ChatMessageUnreadCountDTO(firstMessageId, 2L), + new ChatMessageUnreadCountDTO(secondMessageId, 1L) + )); + + // when + Map result = readStatusReader.countUnreadByMessageIdsAsSparseMap(messageIds); + + // then + assertThat(result).containsEntry(firstMessageId, 2) + .containsEntry(secondMessageId, 1); + } + + @Test + @DisplayName("메시지별 unread 수 sparse Map은 repository 결과에 없는 메시지를 0으로 채우지 않는다") + void countUnreadByMessageIdsAsSparseMap_doesNotFillMissingMessageCount() { + // given + Long unreadMessageId = 201L; + Long fullyReadMessageId = 202L; + List messageIds = List.of(unreadMessageId, fullyReadMessageId); + given(messageReadStatusRepository.countUnreadByMessageIds(messageIds)) + .willReturn(List.of(new ChatMessageUnreadCountDTO(unreadMessageId, 1L))); + + // when + Map result = readStatusReader.countUnreadByMessageIdsAsSparseMap(messageIds); + + // then + assertThat(result).containsEntry(unreadMessageId, 1); + assertThat(result).doesNotContainKey(fullyReadMessageId); + } + + @Test + @DisplayName("메시지별 unread 수 조회는 큰 메시지 ID 목록을 chunk로 나눠 조회한다") + void countUnreadByMessageIdsAsSparseMap_chunksLargeMessageIds() { + // given + List messageIds = LongStream.rangeClosed(1, ReadStatusBatchSupport.IN_CLAUSE_CHUNK_SIZE + 1L) + .boxed() + .toList(); + List firstChunk = messageIds.subList(0, ReadStatusBatchSupport.IN_CLAUSE_CHUNK_SIZE); + List secondChunk = messageIds.subList(ReadStatusBatchSupport.IN_CLAUSE_CHUNK_SIZE, messageIds.size()); + + given(messageReadStatusRepository.countUnreadByMessageIds(firstChunk)) + .willReturn(List.of(new ChatMessageUnreadCountDTO(1L, 2L))); + given(messageReadStatusRepository.countUnreadByMessageIds(secondChunk)) + .willReturn(List.of(new ChatMessageUnreadCountDTO((long) messageIds.size(), 1L))); + + // when + Map result = readStatusReader.countUnreadByMessageIdsAsSparseMap(messageIds); + + // then + assertThat(result).containsEntry(1L, 2) + .containsEntry((long) messageIds.size(), 1); + then(messageReadStatusRepository).should().countUnreadByMessageIds(firstChunk); + then(messageReadStatusRepository).should().countUnreadByMessageIds(secondChunk); + } + + @Test + @DisplayName("메시지 ID 목록이 비어 있으면 unread 수를 조회하지 않는다") + void countUnreadByMessageIdsAsSparseMap_skipsRepositoryWhenMessageIdsEmpty() { + // when + Map result = readStatusReader.countUnreadByMessageIdsAsSparseMap(List.of()); + + // then + assertThat(result).isEmpty(); + then(messageReadStatusRepository).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("단일 메시지 unread 수를 조회한다") + void countUnreadByMessageId_returnsCount() { + // given + Long messageId = 201L; + given(messageReadStatusRepository.countUnreadByMessageId(messageId)).willReturn(3); + + // when + int result = readStatusReader.countUnreadByMessageId(messageId); + + // then + assertThat(result).isEqualTo(3); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/chat/service/support/updater/ChatMemberReadStateUpdaterTest.java b/src/test/java/umc/cockple/demo/domain/chat/service/support/updater/ChatMemberReadStateUpdaterTest.java new file mode 100644 index 000000000..26e555f62 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/chat/service/support/updater/ChatMemberReadStateUpdaterTest.java @@ -0,0 +1,138 @@ +package umc.cockple.demo.domain.chat.service.support.updater; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import umc.cockple.demo.domain.chat.repository.ChatRoomMemberRepository; +import umc.cockple.demo.domain.chat.service.support.ReadStatusBatchSupport; + +import java.util.List; +import java.util.stream.LongStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ChatMemberReadStateUpdater") +class ChatMemberReadStateUpdaterTest { + + @Mock private ChatRoomMemberRepository chatRoomMemberRepository; + + private ChatMemberReadStateUpdater chatMemberReadStateUpdater; + + @BeforeEach + void setUp() { + chatMemberReadStateUpdater = new ChatMemberReadStateUpdater(chatRoomMemberRepository); + } + + @Test + @DisplayName("멤버의 마지막 읽은 메시지 ID를 전진시킨다") + void advanceLastReadMessageId_delegatesToRepository() { + // given + Long chatRoomId = 10L; + Long memberId = 101L; + Long messageId = 201L; + given(chatRoomMemberRepository.advanceLastReadMessageId(chatRoomId, memberId, messageId)) + .willReturn(1); + + // when + int result = chatMemberReadStateUpdater.advanceLastReadMessageId(chatRoomId, memberId, messageId); + + // then + assertThat(result).isEqualTo(1); + } + + @Test + @DisplayName("멤버의 마지막 읽은 메시지 ID가 갱신되지 않으면 0을 반환한다") + void advanceLastReadMessageId_returnsZeroWhenNotUpdated() { + // given + Long chatRoomId = 10L; + Long memberId = 101L; + Long messageId = 201L; + given(chatRoomMemberRepository.advanceLastReadMessageId(chatRoomId, memberId, messageId)) + .willReturn(0); + + // when + int result = chatMemberReadStateUpdater.advanceLastReadMessageId(chatRoomId, memberId, messageId); + + // then + assertThat(result).isZero(); + } + + @Test + @DisplayName("여러 멤버의 마지막 읽은 메시지 ID를 전진시킨다") + void advanceLastReadMessageIdForMembers_delegatesToRepository() { + // given + Long chatRoomId = 10L; + Long messageId = 201L; + List memberIds = List.of(101L, 102L); + given(chatRoomMemberRepository.advanceLastReadMessageIdForMembers(chatRoomId, memberIds, messageId)) + .willReturn(2); + + // when + int result = chatMemberReadStateUpdater.advanceLastReadMessageIdForMembers(chatRoomId, memberIds, messageId); + + // then + assertThat(result).isEqualTo(2); + } + + @Test + @DisplayName("여러 멤버의 마지막 읽은 메시지 ID가 일부만 갱신되면 갱신 수를 반환한다") + void advanceLastReadMessageIdForMembers_returnsPartialUpdatedCount() { + // given + Long chatRoomId = 10L; + Long messageId = 201L; + List memberIds = List.of(101L, 102L); + given(chatRoomMemberRepository.advanceLastReadMessageIdForMembers(chatRoomId, memberIds, messageId)) + .willReturn(1); + + // when + int result = chatMemberReadStateUpdater.advanceLastReadMessageIdForMembers(chatRoomId, memberIds, messageId); + + // then + assertThat(result).isEqualTo(1); + } + + @Test + @DisplayName("여러 멤버의 마지막 읽은 메시지 ID 갱신은 큰 멤버 ID 목록을 chunk로 나눠 처리한다") + void advanceLastReadMessageIdForMembers_chunksLargeMemberIds() { + // given + Long chatRoomId = 10L; + Long messageId = 201L; + List memberIds = LongStream.rangeClosed(1, ReadStatusBatchSupport.IN_CLAUSE_CHUNK_SIZE + 1L) + .boxed() + .toList(); + List firstChunk = memberIds.subList(0, ReadStatusBatchSupport.IN_CLAUSE_CHUNK_SIZE); + List secondChunk = memberIds.subList(ReadStatusBatchSupport.IN_CLAUSE_CHUNK_SIZE, memberIds.size()); + + given(chatRoomMemberRepository.advanceLastReadMessageIdForMembers(chatRoomId, firstChunk, messageId)) + .willReturn(ReadStatusBatchSupport.IN_CLAUSE_CHUNK_SIZE); + given(chatRoomMemberRepository.advanceLastReadMessageIdForMembers(chatRoomId, secondChunk, messageId)) + .willReturn(1); + + // when + int result = chatMemberReadStateUpdater.advanceLastReadMessageIdForMembers(chatRoomId, memberIds, messageId); + + // then + assertThat(result).isEqualTo(memberIds.size()); + then(chatRoomMemberRepository).should() + .advanceLastReadMessageIdForMembers(chatRoomId, firstChunk, messageId); + then(chatRoomMemberRepository).should() + .advanceLastReadMessageIdForMembers(chatRoomId, secondChunk, messageId); + } + + @Test + @DisplayName("마지막 읽은 메시지를 갱신할 멤버 목록이 비어 있으면 갱신하지 않는다") + void advanceLastReadMessageIdForMembers_skipsRepositoryWhenMemberIdsEmpty() { + // when + int result = chatMemberReadStateUpdater.advanceLastReadMessageIdForMembers(10L, List.of(), 201L); + + // then + assertThat(result).isZero(); + then(chatRoomMemberRepository).shouldHaveNoInteractions(); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/chat/service/support/updater/ReadStatusUpdaterTest.java b/src/test/java/umc/cockple/demo/domain/chat/service/support/updater/ReadStatusUpdaterTest.java new file mode 100644 index 000000000..618b46955 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/chat/service/support/updater/ReadStatusUpdaterTest.java @@ -0,0 +1,138 @@ +package umc.cockple.demo.domain.chat.service.support.updater; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import umc.cockple.demo.domain.chat.repository.MessageReadStatusRepository; +import umc.cockple.demo.domain.chat.service.support.ReadStatusBatchSupport; + +import java.util.List; +import java.util.stream.LongStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ReadStatusUpdater") +class ReadStatusUpdaterTest { + + @Mock private MessageReadStatusRepository messageReadStatusRepository; + + private ReadStatusUpdater readStatusUpdater; + + @BeforeEach + void setUp() { + readStatusUpdater = new ReadStatusUpdater(messageReadStatusRepository); + } + + @Test + @DisplayName("멤버의 메시지 목록을 읽음 처리한다") + void markMessagesAsReadForMember_delegatesToRepository() { + // given + Long chatRoomId = 10L; + Long memberId = 101L; + List messageIds = List.of(201L, 202L); + given(messageReadStatusRepository.markMessagesAsReadForMember(chatRoomId, memberId, messageIds)) + .willReturn(2); + + // when + int result = readStatusUpdater.markMessagesAsReadForMember(chatRoomId, memberId, messageIds); + + // then + assertThat(result).isEqualTo(2); + } + + @Test + @DisplayName("멤버의 메시지 목록 읽음 처리는 큰 메시지 ID 목록을 chunk로 나눠 갱신한다") + void markMessagesAsReadForMember_chunksLargeMessageIds() { + // given + Long chatRoomId = 10L; + Long memberId = 101L; + List messageIds = LongStream.rangeClosed(1, ReadStatusBatchSupport.IN_CLAUSE_CHUNK_SIZE + 1L) + .boxed() + .toList(); + List firstChunk = messageIds.subList(0, ReadStatusBatchSupport.IN_CLAUSE_CHUNK_SIZE); + List secondChunk = messageIds.subList(ReadStatusBatchSupport.IN_CLAUSE_CHUNK_SIZE, messageIds.size()); + + given(messageReadStatusRepository.markMessagesAsReadForMember(chatRoomId, memberId, firstChunk)) + .willReturn(ReadStatusBatchSupport.IN_CLAUSE_CHUNK_SIZE); + given(messageReadStatusRepository.markMessagesAsReadForMember(chatRoomId, memberId, secondChunk)) + .willReturn(1); + + // when + int result = readStatusUpdater.markMessagesAsReadForMember(chatRoomId, memberId, messageIds); + + // then + assertThat(result).isEqualTo(messageIds.size()); + then(messageReadStatusRepository).should() + .markMessagesAsReadForMember(chatRoomId, memberId, firstChunk); + then(messageReadStatusRepository).should() + .markMessagesAsReadForMember(chatRoomId, memberId, secondChunk); + } + + @Test + @DisplayName("읽음 처리할 메시지 목록이 비어 있으면 갱신하지 않는다") + void markMessagesAsReadForMember_skipsRepositoryWhenMessageIdsEmpty() { + // when + int result = readStatusUpdater.markMessagesAsReadForMember(10L, 101L, List.of()); + + // then + assertThat(result).isZero(); + then(messageReadStatusRepository).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("메시지를 여러 멤버에 대해 읽음 처리한다") + void markMessageAsReadForMembers_delegatesToRepository() { + // given + Long messageId = 201L; + List memberIds = List.of(101L, 102L); + given(messageReadStatusRepository.markAsReadInMembers(messageId, memberIds)).willReturn(2); + + // when + int result = readStatusUpdater.markMessageAsReadForMembers(messageId, memberIds); + + // then + assertThat(result).isEqualTo(2); + } + + @Test + @DisplayName("여러 멤버 읽음 처리는 큰 멤버 ID 목록을 chunk로 나눠 갱신한다") + void markMessageAsReadForMembers_chunksLargeMemberIds() { + // given + Long messageId = 201L; + List memberIds = LongStream.rangeClosed(1, ReadStatusBatchSupport.IN_CLAUSE_CHUNK_SIZE + 1L) + .boxed() + .toList(); + List firstChunk = memberIds.subList(0, ReadStatusBatchSupport.IN_CLAUSE_CHUNK_SIZE); + List secondChunk = memberIds.subList(ReadStatusBatchSupport.IN_CLAUSE_CHUNK_SIZE, memberIds.size()); + + given(messageReadStatusRepository.markAsReadInMembers(messageId, firstChunk)) + .willReturn(ReadStatusBatchSupport.IN_CLAUSE_CHUNK_SIZE); + given(messageReadStatusRepository.markAsReadInMembers(messageId, secondChunk)) + .willReturn(1); + + // when + int result = readStatusUpdater.markMessageAsReadForMembers(messageId, memberIds); + + // then + assertThat(result).isEqualTo(memberIds.size()); + then(messageReadStatusRepository).should().markAsReadInMembers(messageId, firstChunk); + then(messageReadStatusRepository).should().markAsReadInMembers(messageId, secondChunk); + } + + @Test + @DisplayName("읽음 처리할 멤버 목록이 비어 있으면 갱신하지 않는다") + void markMessageAsReadForMembers_skipsRepositoryWhenMemberIdsEmpty() { + // when + int result = readStatusUpdater.markMessageAsReadForMembers(201L, List.of()); + + // then + assertThat(result).isZero(); + then(messageReadStatusRepository).shouldHaveNoInteractions(); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/chat/service/websocket/broadcast/ChatRoomListUpdateBroadcasterTest.java b/src/test/java/umc/cockple/demo/domain/chat/service/websocket/broadcast/ChatRoomListUpdateBroadcasterTest.java new file mode 100644 index 000000000..7557cbd57 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/chat/service/websocket/broadcast/ChatRoomListUpdateBroadcasterTest.java @@ -0,0 +1,92 @@ +package umc.cockple.demo.domain.chat.service.websocket.broadcast; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import umc.cockple.demo.domain.chat.dto.WebSocketMessageDTO; +import umc.cockple.demo.domain.chat.dto.WebSocketMessageDTO.ChatRoomListUpdate.LastMessageUpdate; +import umc.cockple.demo.domain.chat.enums.WebSocketMessageType; +import umc.cockple.demo.domain.chat.repository.redis.ChatListSubscriptionStore; +import umc.cockple.demo.domain.chat.service.websocket.session.ChatMessageEncoder; +import umc.cockple.demo.domain.chat.service.websocket.session.ChatMessageSender; +import umc.cockple.demo.domain.chat.service.websocket.session.EncodedChatMessage; + +import java.time.LocalDateTime; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ChatRoomListUpdateBroadcaster") +class ChatRoomListUpdateBroadcasterTest { + + @Mock private ChatListSubscriptionStore chatListSubscriptionStore; + @Mock private ChatMessageEncoder messageEncoder; + @Mock private ChatMessageSender messageSender; + + private ChatRoomListUpdateBroadcaster broadcaster; + + @BeforeEach + void setUp() { + broadcaster = new ChatRoomListUpdateBroadcaster(chatListSubscriptionStore, messageEncoder, messageSender); + } + + @Test + @DisplayName("채팅방 목록 구독자에게만 개별 목록 업데이트를 전송한다") + void broadcast_sendsOnlyToChatListSubscribers() { + // given + Long chatRoomId = 1L; + Long subscribedMemberId = 10L; + Long unsubscribedMemberId = 20L; + ChatRoomListUpdateData updateData = createUpdateData(3); + Map memberUpdateData = Map.of( + subscribedMemberId, updateData, + unsubscribedMemberId, createUpdateData(1) + ); + + given(chatListSubscriptionStore.getChatListSubscribers(chatRoomId)) + .willReturn(Set.of(subscribedMemberId)); + EncodedChatMessage encodedMessage = new EncodedChatMessage("list-update-json"); + given(messageEncoder.encode(org.mockito.ArgumentMatchers.any(WebSocketMessageDTO.ChatRoomListUpdate.class))) + .willReturn(Optional.of(encodedMessage)); + given(messageSender.send(subscribedMemberId, encodedMessage)) + .willReturn(true); + + // when + broadcaster.broadcast(chatRoomId, memberUpdateData); + + // then + ArgumentCaptor messageCaptor = + ArgumentCaptor.forClass(WebSocketMessageDTO.ChatRoomListUpdate.class); + then(messageEncoder).should().encode(messageCaptor.capture()); + then(messageSender).should().send(subscribedMemberId, encodedMessage); + then(messageSender).shouldHaveNoMoreInteractions(); + + WebSocketMessageDTO.ChatRoomListUpdate message = messageCaptor.getValue(); + assertThat(message.type()).isEqualTo(WebSocketMessageType.CHAT_ROOM_LIST_UPDATE); + assertThat(message.chatRoomId()).isEqualTo(chatRoomId); + assertThat(message.lastMessage()).isEqualTo(updateData.lastMessage()); + assertThat(message.newUnreadCount()).isEqualTo(updateData.unreadCount()); + } + + private ChatRoomListUpdateData createUpdateData(int unreadCount) { + LastMessageUpdate lastMessage = LastMessageUpdate.builder() + .content("hello") + .timestamp(LocalDateTime.of(2026, 5, 21, 13, 15)) + .messageType("TEXT") + .build(); + + return ChatRoomListUpdateData.builder() + .lastMessage(lastMessage) + .unreadCount(unreadCount) + .build(); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/chat/service/websocket/broadcast/ChatRoomMessageBroadcasterTest.java b/src/test/java/umc/cockple/demo/domain/chat/service/websocket/broadcast/ChatRoomMessageBroadcasterTest.java new file mode 100644 index 000000000..608f4c414 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/chat/service/websocket/broadcast/ChatRoomMessageBroadcasterTest.java @@ -0,0 +1,88 @@ +package umc.cockple.demo.domain.chat.service.websocket.broadcast; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import umc.cockple.demo.domain.chat.dto.WebSocketMessageDTO; +import umc.cockple.demo.domain.chat.enums.MessageType; +import umc.cockple.demo.domain.chat.enums.WebSocketMessageType; +import umc.cockple.demo.domain.chat.service.websocket.session.ChatMessageEncoder; +import umc.cockple.demo.domain.chat.service.websocket.session.ChatMessageSender; +import umc.cockple.demo.domain.chat.service.websocket.session.EncodedChatMessage; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ChatRoomMessageBroadcaster") +class ChatRoomMessageBroadcasterTest { + + @Mock private ChatMessageEncoder messageEncoder; + @Mock private ChatMessageSender messageSender; + + private ChatRoomMessageBroadcaster broadcaster; + + @BeforeEach + void setUp() { + broadcaster = new ChatRoomMessageBroadcaster(messageEncoder, messageSender); + } + + @Test + @DisplayName("제외 멤버를 건너뛰고 나머지 구독자에게 인코딩된 메시지를 전송한다") + void broadcast_sendsEncodedMessageExceptExcludedMember() { + // given + Long chatRoomId = 1L; + Long excludedMemberId = 10L; + WebSocketMessageDTO.MessageResponse message = createMessage(chatRoomId); + EncodedChatMessage encodedMessage = new EncodedChatMessage("message-json"); + given(messageEncoder.encode(message)).willReturn(Optional.of(encodedMessage)); + given(messageSender.send(20L, encodedMessage)).willReturn(true); + given(messageSender.send(30L, encodedMessage)).willReturn(false); + + // when + broadcaster.broadcast(chatRoomId, message, List.of(excludedMemberId, 20L, 30L), excludedMemberId); + + // then + then(messageEncoder).should().encode(message); + then(messageSender).should().send(20L, encodedMessage); + then(messageSender).should().send(30L, encodedMessage); + then(messageSender).shouldHaveNoMoreInteractions(); + } + + @Test + @DisplayName("구독자가 없으면 메시지를 직렬화하지 않는다") + void broadcast_doesNotSerializeWhenSubscribersEmpty() { + // given + Long chatRoomId = 1L; + WebSocketMessageDTO.MessageResponse message = createMessage(chatRoomId); + + // when + broadcaster.broadcast(chatRoomId, message, List.of(), null); + + // then + then(messageEncoder).shouldHaveNoInteractions(); + then(messageSender).shouldHaveNoInteractions(); + } + + private WebSocketMessageDTO.MessageResponse createMessage(Long chatRoomId) { + return WebSocketMessageDTO.MessageResponse.builder() + .type(WebSocketMessageType.SEND) + .chatRoomId(chatRoomId) + .messageId(100L) + .content("hello") + .messageType(MessageType.TEXT) + .images(List.of()) + .senderId(10L) + .senderName("sender") + .timestamp(LocalDateTime.of(2026, 5, 21, 13, 15)) + .unreadCount(1) + .build(); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/chat/service/websocket/broadcast/UnreadCountUpdateBroadcasterTest.java b/src/test/java/umc/cockple/demo/domain/chat/service/websocket/broadcast/UnreadCountUpdateBroadcasterTest.java new file mode 100644 index 000000000..0af2285c1 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/chat/service/websocket/broadcast/UnreadCountUpdateBroadcasterTest.java @@ -0,0 +1,101 @@ +package umc.cockple.demo.domain.chat.service.websocket.broadcast; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import umc.cockple.demo.domain.chat.dto.WebSocketMessageDTO; +import umc.cockple.demo.domain.chat.enums.WebSocketMessageType; +import umc.cockple.demo.domain.chat.service.websocket.UnreadCountUpdate; +import umc.cockple.demo.domain.chat.service.websocket.session.ChatMessageEncoder; +import umc.cockple.demo.domain.chat.service.websocket.session.ChatMessageSender; +import umc.cockple.demo.domain.chat.service.websocket.session.EncodedChatMessage; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +@ExtendWith(MockitoExtension.class) +@DisplayName("UnreadCountUpdateBroadcaster") +class UnreadCountUpdateBroadcasterTest { + + @Mock private ChatMessageEncoder messageEncoder; + @Mock private ChatMessageSender messageSender; + + private UnreadCountUpdateBroadcaster broadcaster; + + @BeforeEach + void setUp() { + broadcaster = new UnreadCountUpdateBroadcaster(messageEncoder, messageSender); + } + + @Test + @DisplayName("읽은 멤버를 제외하고 메시지별 안읽은 수 업데이트를 전송한다") + void broadcast_sendsUnreadCountUpdateExceptExcludedMember() { + // given + Long chatRoomId = 1L; + Long excludedMemberId = 10L; + UnreadCountUpdate update = + new UnreadCountUpdate(100L, 2); + EncodedChatMessage encodedMessage = new EncodedChatMessage("unread-count-json"); + + given(messageEncoder.encode(any(WebSocketMessageDTO.UnreadCountUpdateMessage.class))) + .willReturn(Optional.of(encodedMessage)); + given(messageSender.send(20L, encodedMessage)).willReturn(true); + + // when + broadcaster.broadcast(chatRoomId, List.of(update), List.of(excludedMemberId, 20L), excludedMemberId); + + // then + ArgumentCaptor messageCaptor = + ArgumentCaptor.forClass(WebSocketMessageDTO.UnreadCountUpdateMessage.class); + then(messageEncoder).should().encode(messageCaptor.capture()); + WebSocketMessageDTO.UnreadCountUpdateMessage message = messageCaptor.getValue(); + assertThat(message.type()).isEqualTo(WebSocketMessageType.UNREAD_COUNT_UPDATE); + assertThat(message.chatRoomId()).isEqualTo(chatRoomId); + assertThat(message.messageId()).isEqualTo(update.messageId()); + assertThat(message.newUnreadCount()).isEqualTo(update.newUnreadCount()); + + then(messageSender).should().send(20L, encodedMessage); + then(messageSender).shouldHaveNoMoreInteractions(); + } + + @Test + @DisplayName("구독자가 없으면 메시지를 직렬화하지 않는다") + void broadcast_doesNotSerializeWhenSubscribersEmpty() { + // given + UnreadCountUpdate update = + new UnreadCountUpdate(100L, 2); + + // when + broadcaster.broadcast(1L, List.of(update), List.of(), 10L); + + // then + then(messageEncoder).shouldHaveNoInteractions(); + then(messageSender).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("직렬화 실패 시 전송하지 않고 다음 업데이트로 넘어간다") + void broadcast_doesNotSendWhenSerializationFails() { + // given + UnreadCountUpdate update = + new UnreadCountUpdate(100L, 2); + given(messageEncoder.encode(any(WebSocketMessageDTO.UnreadCountUpdateMessage.class))) + .willReturn(Optional.empty()); + + // when + broadcaster.broadcast(1L, List.of(update), List.of(20L), 10L); + + // then + then(messageEncoder).should().encode(any(WebSocketMessageDTO.UnreadCountUpdateMessage.class)); + then(messageSender).shouldHaveNoMoreInteractions(); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/chat/service/websocket/send/ChatSendServiceTest.java b/src/test/java/umc/cockple/demo/domain/chat/service/websocket/send/ChatSendServiceTest.java new file mode 100644 index 000000000..39e2025ef --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/chat/service/websocket/send/ChatSendServiceTest.java @@ -0,0 +1,195 @@ +package umc.cockple.demo.domain.chat.service.websocket.send; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import umc.cockple.demo.domain.chat.converter.ChatWebSocketResponseAssembler; +import umc.cockple.demo.domain.chat.domain.ChatMessage; +import umc.cockple.demo.domain.chat.domain.ChatRoom; +import umc.cockple.demo.domain.chat.dto.WebSocketMessageDTO; +import umc.cockple.demo.domain.chat.enums.MessageType; +import umc.cockple.demo.domain.chat.repository.ChatMessageRepository; +import umc.cockple.demo.domain.chat.service.ChatProcessor; +import umc.cockple.demo.domain.chat.service.websocket.send.support.ChatMessageFileAppender; +import umc.cockple.demo.domain.chat.service.websocket.send.support.ChatSendEventPublisher; +import umc.cockple.demo.domain.chat.service.websocket.send.support.DirectChatRoomActivationService; +import umc.cockple.demo.domain.chat.service.support.reader.ChatMemberReader; +import umc.cockple.demo.domain.chat.service.support.reader.ChatRoomReader; +import umc.cockple.demo.domain.chat.service.websocket.send.support.SentMessageReadStatusService; +import umc.cockple.demo.domain.chat.service.websocket.send.ChatSendService; +import umc.cockple.demo.domain.chat.service.websocket.send.support.MessageReadCreationService; +import umc.cockple.demo.domain.chat.service.websocket.broadcast.ChatRoomMessageBroadcaster; +import umc.cockple.demo.domain.chat.service.websocket.subscription.support.ActiveChatRoomSubscriberReader; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.party.domain.Party; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.support.fixture.ChatFixture; +import umc.cockple.demo.support.fixture.MemberFixture; +import umc.cockple.demo.support.fixture.PartyFixture; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ChatSendService") +class ChatSendServiceTest { + + @Mock private ChatMessageRepository chatMessageRepository; + @Mock private ChatRoomReader chatRoomReader; + @Mock private ChatMemberReader chatMemberReader; + @Mock private ChatMessageFileAppender chatMessageFileAppender; + @Mock private DirectChatRoomActivationService directChatRoomActivationService; + @Mock private ChatSendEventPublisher chatSendEventPublisher; + @Mock private ActiveChatRoomSubscriberReader activeChatRoomSubscriberReader; + @Mock private ChatRoomMessageBroadcaster chatRoomMessageBroadcaster; + @Mock private MessageReadCreationService messageReadCreationService; + @Mock private ChatProcessor chatProcessor; + @Mock private SentMessageReadStatusService sentMessageReadStatusService; + + private ChatSendService chatSendService; + private ChatWebSocketResponseAssembler chatWebSocketResponseAssembler; + + @BeforeEach + void setUp() { + chatWebSocketResponseAssembler = new ChatWebSocketResponseAssembler(); + chatSendService = new ChatSendService( + chatMessageRepository, + chatRoomReader, + chatMemberReader, + chatMessageFileAppender, + directChatRoomActivationService, + chatSendEventPublisher, + activeChatRoomSubscriberReader, + chatRoomMessageBroadcaster, + messageReadCreationService, + chatProcessor, + chatWebSocketResponseAssembler, + sentMessageReadStatusService + ); + } + + @Test + @DisplayName("일반 메시지를 저장하면 읽음 상태, 브로드캐스트, 후속 이벤트 발행을 위임한다") + void sendMessage_savesBroadcastsAndDelegatesEvents() { + // given + Long roomId = 20L; + Long senderId = 101L; + LocalDateTime sentAt = LocalDateTime.of(2026, 5, 21, 13, 15); + + Member sender = MemberFixture.createMemberWithName("홍길동", "길동", Gender.MALE, Level.A, 1001L); + ReflectionTestUtils.setField(sender, "id", senderId); + + Party party = PartyFixture.createParty("배드민턴 모임", senderId, PartyFixture.createPartyAddr("서울", "강남구")); + ChatRoom chatRoom = ChatFixture.createPartyChatRoom(party); + ReflectionTestUtils.setField(chatRoom, "id", roomId); + + given(chatRoomReader.read(roomId)).willReturn(chatRoom); + given(chatMemberReader.readWithProfile(senderId)).willReturn(sender); + given(chatProcessor.generateProfileImageUrl(isNull())).willReturn("https://cdn.example.com/profile"); + given(chatMessageRepository.save(any(ChatMessage.class))).willAnswer(invocation -> { + ChatMessage savedMessage = invocation.getArgument(0); + ReflectionTestUtils.setField(savedMessage, "id", 300L); + ReflectionTestUtils.setField(savedMessage, "createdAt", sentAt); + return savedMessage; + }); + given(activeChatRoomSubscriberReader.findActiveSubscribers(roomId)).willReturn(List.of(senderId)); + given(sentMessageReadStatusService.markActiveSubscribersAsRead(roomId, 300L, List.of(senderId), senderId)).willReturn(2); + + // when + chatSendService.sendMessage(roomId, "안녕하세요", List.of(), senderId); + + // then + ArgumentCaptor savedMessageCaptor = ArgumentCaptor.forClass(ChatMessage.class); + then(chatMessageRepository).should().save(savedMessageCaptor.capture()); + ChatMessage savedMessage = savedMessageCaptor.getValue(); + assertThat(savedMessage.getType()).isEqualTo(MessageType.TEXT); + assertThat(savedMessage.getSender()).isSameAs(sender); + assertThat(savedMessage.getContent()).isEqualTo("안녕하세요"); + + then(chatMessageFileAppender).should().append(savedMessage, List.of()); + then(directChatRoomActivationService).should().joinPendingMemberOnFirstMessage(chatRoom, senderId); + then(messageReadCreationService).should().createReadStatusForNewMessage(savedMessage, senderId); + then(sentMessageReadStatusService).should().markActiveSubscribersAsRead(roomId, 300L, List.of(senderId), senderId); + + ArgumentCaptor messageResponseCaptor = + ArgumentCaptor.forClass(WebSocketMessageDTO.MessageResponse.class); + then(chatRoomMessageBroadcaster).should() + .broadcast(eq(roomId), messageResponseCaptor.capture(), eq(List.of(senderId)), eq(senderId)); + assertThat(messageResponseCaptor.getValue().messageId()).isEqualTo(300L); + assertThat(messageResponseCaptor.getValue().unreadCount()).isEqualTo(2); + + then(chatSendEventPublisher).should() + .publishChatNotificationEvent(chatRoom, savedMessage, sender, List.of(senderId)); + then(chatSendEventPublisher).should().publishChatRoomListUpdateEvent(chatRoom, savedMessage); + then(chatSendEventPublisher).should().publishUnreadStatusUpdateEvent(chatRoom, senderId); + } + + @Test + @DisplayName("시스템 메시지를 저장하면 읽음 상태, 브로드캐스트, 채팅방 목록 업데이트 이벤트를 위임한다") + void sendSystemMessage_savesBroadcastsAndDelegatesListUpdate() { + // given + Long partyId = 10L; + Long roomId = 20L; + LocalDateTime sentAt = LocalDateTime.of(2026, 5, 21, 13, 15); + String content = "홍길동님이 모임에 참여하셨습니다."; + + Party party = PartyFixture.createParty("배드민턴 모임", 101L, PartyFixture.createPartyAddr("서울", "강남구")); + ReflectionTestUtils.setField(party, "id", partyId); + + ChatRoom chatRoom = ChatFixture.createPartyChatRoom(party); + ReflectionTestUtils.setField(chatRoom, "id", roomId); + + given(chatRoomReader.readByPartyId(partyId)).willReturn(chatRoom); + given(chatMessageRepository.save(any(ChatMessage.class))).willAnswer(invocation -> { + ChatMessage savedMessage = invocation.getArgument(0); + ReflectionTestUtils.setField(savedMessage, "id", 300L); + ReflectionTestUtils.setField(savedMessage, "createdAt", sentAt); + return savedMessage; + }); + given(activeChatRoomSubscriberReader.findActiveSubscribers(roomId)).willReturn(List.of(101L)); + given(sentMessageReadStatusService.markActiveSubscribersAsRead(eq(roomId), anyLong(), eq(List.of(101L)), isNull())).willReturn(1); + + // when + chatSendService.sendSystemMessage(partyId, content); + + // then + ArgumentCaptor savedMessageCaptor = ArgumentCaptor.forClass(ChatMessage.class); + then(chatMessageRepository).should().save(savedMessageCaptor.capture()); + ChatMessage savedMessage = savedMessageCaptor.getValue(); + assertThat(savedMessage.getType()).isEqualTo(MessageType.SYSTEM); + assertThat(savedMessage.getSender()).isNull(); + assertThat(savedMessage.getContent()).isEqualTo(content); + + then(messageReadCreationService).should().createReadStatusForNewMessage(savedMessage, null); + then(sentMessageReadStatusService).should().markActiveSubscribersAsRead(roomId, 300L, List.of(101L), null); + + ArgumentCaptor messageResponseCaptor = + ArgumentCaptor.forClass(WebSocketMessageDTO.MessageResponse.class); + then(chatRoomMessageBroadcaster).should() + .broadcast(eq(roomId), messageResponseCaptor.capture(), eq(List.of(101L)), isNull()); + WebSocketMessageDTO.MessageResponse response = messageResponseCaptor.getValue(); + assertThat(response.chatRoomId()).isEqualTo(roomId); + assertThat(response.messageId()).isEqualTo(300L); + assertThat(response.content()).isEqualTo(content); + assertThat(response.messageType()).isEqualTo(MessageType.SYSTEM); + assertThat(response.senderId()).isNull(); + assertThat(response.senderName()).isEqualTo("시스템"); + + then(chatSendEventPublisher).should().publishChatRoomListUpdateEvent(chatRoom, savedMessage); + then(chatSendEventPublisher).should().publishUnreadStatusUpdateEvent(chatRoom, null); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/chat/service/websocket/send/support/ChatMessageFileAppenderTest.java b/src/test/java/umc/cockple/demo/domain/chat/service/websocket/send/support/ChatMessageFileAppenderTest.java new file mode 100644 index 000000000..87df2bd17 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/chat/service/websocket/send/support/ChatMessageFileAppenderTest.java @@ -0,0 +1,74 @@ +package umc.cockple.demo.domain.chat.service.websocket.send.support; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import umc.cockple.demo.domain.chat.domain.ChatMessage; +import umc.cockple.demo.domain.chat.domain.ChatMessageFile; +import umc.cockple.demo.domain.chat.domain.ChatRoom; +import umc.cockple.demo.domain.chat.dto.WebSocketMessageDTO; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.support.fixture.ChatFixture; +import umc.cockple.demo.support.fixture.MemberFixture; +import umc.cockple.demo.support.fixture.PartyFixture; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("ChatMessageFileAppender") +class ChatMessageFileAppenderTest { + + private final ChatMessageFileAppender chatMessageFileAppender = new ChatMessageFileAppender(); + + @Test + @DisplayName("첨부 파일 요청이 없으면 메시지 파일 목록을 변경하지 않는다") + void append_doesNothing_whenFilesAreEmpty() { + // given + ChatMessage message = createMessage(); + + // when + chatMessageFileAppender.append(message, List.of()); + + // then + assertThat(message.getChatMessageFiles()).isEmpty(); + } + + @Test + @DisplayName("첨부 파일 요청을 ChatMessageFile로 변환해 메시지에 추가한다") + void append_addsMessageFiles() { + // given + ChatMessage message = createMessage(); + WebSocketMessageDTO.Request.FileInfo fileInfo = WebSocketMessageDTO.Request.FileInfo.builder() + .imgKey("chat/image.png") + .imgOrder(2) + .originalFileName("image.png") + .fileSize(2048L) + .fileType("image/png") + .build(); + + // when + chatMessageFileAppender.append(message, List.of(fileInfo)); + + // then + assertThat(message.getChatMessageFiles()).hasSize(1); + ChatMessageFile appendedFile = message.getChatMessageFiles().get(0); + assertThat(appendedFile.getChatMessage()).isSameAs(message); + assertThat(appendedFile.getFileKey()).isEqualTo("chat/image.png"); + assertThat(appendedFile.getFileOrder()).isEqualTo(2); + assertThat(appendedFile.getOriginalFileName()).isEqualTo("image.png"); + assertThat(appendedFile.getFileSize()).isEqualTo(2048L); + assertThat(appendedFile.getFileType()).isEqualTo("image/png"); + } + + private ChatMessage createMessage() { + Member sender = MemberFixture.createMemberWithName("홍길동", "길동", Gender.MALE, Level.A, 1001L); + ReflectionTestUtils.setField(sender, "id", 10L); + ChatRoom chatRoom = ChatFixture.createPartyChatRoom( + PartyFixture.createParty("모임", sender.getId(), PartyFixture.createPartyAddr("서울", "강남구")) + ); + return ChatFixture.createTextMessage(chatRoom, sender, "안녕하세요"); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/chat/service/websocket/send/support/ChatSendEventPublisherTest.java b/src/test/java/umc/cockple/demo/domain/chat/service/websocket/send/support/ChatSendEventPublisherTest.java new file mode 100644 index 000000000..bcac2ef55 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/chat/service/websocket/send/support/ChatSendEventPublisherTest.java @@ -0,0 +1,183 @@ +package umc.cockple.demo.domain.chat.service.websocket.send.support; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; +import umc.cockple.demo.domain.chat.domain.ChatMessage; +import umc.cockple.demo.domain.chat.domain.ChatRoom; +import umc.cockple.demo.domain.chat.enums.ChatRoomType; +import umc.cockple.demo.domain.chat.enums.MessageType; +import umc.cockple.demo.domain.chat.events.ChatRoomListUpdateEvent; +import umc.cockple.demo.domain.chat.events.ChatUnreadStatusUpdateEvent; +import umc.cockple.demo.domain.chat.repository.ChatRoomMemberRepository; +import umc.cockple.demo.domain.chat.service.ChatUnreadQueryService; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.notification.events.ChatNotificationEvent; +import umc.cockple.demo.domain.party.domain.Party; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.support.fixture.ChatFixture; +import umc.cockple.demo.support.fixture.MemberFixture; +import umc.cockple.demo.support.fixture.PartyFixture; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ChatSendEventPublisher") +class ChatSendEventPublisherTest { + + @Mock private ChatRoomMemberRepository chatRoomMemberRepository; + @Mock private ChatUnreadQueryService chatUnreadQueryService; + @Mock private ApplicationEventPublisher eventPublisher; + + private ChatSendEventPublisher chatSendEventPublisher; + + @BeforeEach + void setUp() { + chatSendEventPublisher = + new ChatSendEventPublisher(chatRoomMemberRepository, chatUnreadQueryService, eventPublisher); + } + + @Test + @DisplayName("모임 채팅 알림 이벤트를 생성해 발행한다") + void publishChatNotificationEvent_publishesPartyNotification() { + // given + Long roomId = 20L; + Long senderId = 101L; + Member sender = MemberFixture.createMemberWithName("홍길동", "길동", Gender.MALE, Level.A, 1001L); + ReflectionTestUtils.setField(sender, "id", senderId); + + Party party = PartyFixture.createParty("배드민턴 모임", senderId, PartyFixture.createPartyAddr("서울", "강남구")); + ChatRoom chatRoom = ChatFixture.createPartyChatRoom(party); + ReflectionTestUtils.setField(chatRoom, "id", roomId); + + ChatMessage message = ChatFixture.createTextMessage(chatRoom, sender, "안녕하세요"); + + // when + chatSendEventPublisher.publishChatNotificationEvent(chatRoom, message, sender, List.of(senderId)); + + // then + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(ChatNotificationEvent.class); + then(eventPublisher).should().publishEvent(eventCaptor.capture()); + ChatNotificationEvent event = eventCaptor.getValue(); + assertThat(event.chatRoomId()).isEqualTo(roomId); + assertThat(event.chatRoomType()).isEqualTo(ChatRoomType.PARTY); + assertThat(event.notificationTitle()).isEqualTo("배드민턴 모임"); + assertThat(event.notificationContent()).isEqualTo("길동: 안녕하세요"); + assertThat(event.senderId()).isEqualTo(senderId); + assertThat(event.activeSubscriberIds()).containsExactly(senderId); + } + + @Test + @DisplayName("채팅방 목록 업데이트 이벤트를 unread count와 함께 발행한다") + void publishChatRoomListUpdateEvent_publishesListUpdate() { + // given + Long roomId = 20L; + LocalDateTime sentAt = LocalDateTime.of(2026, 5, 21, 13, 15); + ChatRoom chatRoom = ChatFixture.createPartyChatRoom( + PartyFixture.createParty("배드민턴 모임", 101L, PartyFixture.createPartyAddr("서울", "강남구")) + ); + ReflectionTestUtils.setField(chatRoom, "id", roomId); + + ChatMessage message = ChatFixture.createSystemMessage(chatRoom, "홍길동님이 모임에 참여하셨습니다."); + ReflectionTestUtils.setField(message, "createdAt", sentAt); + + given(chatRoomMemberRepository.findMemberIdsByChatRoomId(roomId)).willReturn(List.of(101L, 102L)); + given(chatUnreadQueryService.countUnreadMessagesByMembers(roomId, List.of(101L, 102L))) + .willReturn(Map.of(101L, 0, 102L, 1)); + + // when + chatSendEventPublisher.publishChatRoomListUpdateEvent(chatRoom, message); + + // then + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(ChatRoomListUpdateEvent.class); + then(eventPublisher).should().publishEvent(eventCaptor.capture()); + ChatRoomListUpdateEvent event = eventCaptor.getValue(); + assertThat(event.chatRoomId()).isEqualTo(roomId); + assertThat(event.content()).isEqualTo("홍길동님이 모임에 참여하셨습니다."); + assertThat(event.timestamp()).isEqualTo(sentAt); + assertThat(event.messageType()).isEqualTo(MessageType.SYSTEM.name()); + assertThat(event.memberUnreadCounts()).containsEntry(101L, 0).containsEntry(102L, 1); + } + + @Test + @DisplayName("채팅방 목록 unread count 계산에 실패하면 이벤트를 발행하지 않는다") + void publishChatRoomListUpdateEvent_doesNotPublishWhenUnreadCountFails() { + // given + Long roomId = 20L; + ChatRoom chatRoom = ChatFixture.createPartyChatRoom( + PartyFixture.createParty("배드민턴 모임", 101L, PartyFixture.createPartyAddr("서울", "강남구")) + ); + ReflectionTestUtils.setField(chatRoom, "id", roomId); + ChatMessage message = ChatFixture.createSystemMessage(chatRoom, "시스템 메시지"); + + given(chatRoomMemberRepository.findMemberIdsByChatRoomId(roomId)).willReturn(List.of(101L, 102L)); + given(chatUnreadQueryService.countUnreadMessagesByMembers(roomId, List.of(101L, 102L))) + .willThrow(new RuntimeException("batch unread count failed")); + + // when + chatSendEventPublisher.publishChatRoomListUpdateEvent(chatRoom, message); + + // then + then(eventPublisher).should(never()).publishEvent(any(ChatRoomListUpdateEvent.class)); + } + + @Test + @DisplayName("안읽음 상태 업데이트 이벤트는 발신자를 제외하고 중복을 제거해 발행한다") + void publishUnreadStatusUpdateEvent_publishesTargetsExceptSender() { + // given + Long roomId = 20L; + Long senderId = 101L; + ChatRoom chatRoom = ChatFixture.createPartyChatRoom( + PartyFixture.createParty("배드민턴 모임", senderId, PartyFixture.createPartyAddr("서울", "강남구")) + ); + ReflectionTestUtils.setField(chatRoom, "id", roomId); + given(chatRoomMemberRepository.findMemberIdsByChatRoomId(roomId)) + .willReturn(List.of(senderId, 102L, 102L, 103L)); + + // when + chatSendEventPublisher.publishUnreadStatusUpdateEvent(chatRoom, senderId); + + // then + ArgumentCaptor eventCaptor = + ArgumentCaptor.forClass(ChatUnreadStatusUpdateEvent.class); + then(eventPublisher).should().publishEvent(eventCaptor.capture()); + assertThat(eventCaptor.getValue().targetMemberIds()).containsExactly(102L, 103L); + } + + @Test + @DisplayName("시스템 메시지 안읽음 상태 업데이트 이벤트는 모든 멤버를 대상으로 발행한다") + void publishUnreadStatusUpdateEvent_publishesAllTargetsWhenSenderIsNull() { + // given + Long roomId = 20L; + ChatRoom chatRoom = ChatFixture.createPartyChatRoom( + PartyFixture.createParty("배드민턴 모임", 101L, PartyFixture.createPartyAddr("서울", "강남구")) + ); + ReflectionTestUtils.setField(chatRoom, "id", roomId); + given(chatRoomMemberRepository.findMemberIdsByChatRoomId(roomId)) + .willReturn(List.of(101L, 102L, 102L)); + + // when + chatSendEventPublisher.publishUnreadStatusUpdateEvent(chatRoom, null); + + // then + ArgumentCaptor eventCaptor = + ArgumentCaptor.forClass(ChatUnreadStatusUpdateEvent.class); + then(eventPublisher).should().publishEvent(eventCaptor.capture()); + assertThat(eventCaptor.getValue().targetMemberIds()).containsExactly(101L, 102L); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/chat/service/websocket/send/support/DirectChatRoomActivationServiceTest.java b/src/test/java/umc/cockple/demo/domain/chat/service/websocket/send/support/DirectChatRoomActivationServiceTest.java new file mode 100644 index 000000000..224022a06 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/chat/service/websocket/send/support/DirectChatRoomActivationServiceTest.java @@ -0,0 +1,103 @@ +package umc.cockple.demo.domain.chat.service.websocket.send.support; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import umc.cockple.demo.domain.chat.domain.ChatRoom; +import umc.cockple.demo.domain.chat.domain.ChatRoomMember; +import umc.cockple.demo.domain.chat.enums.ChatRoomMemberStatus; +import umc.cockple.demo.domain.chat.repository.ChatMessageRepository; +import umc.cockple.demo.domain.chat.repository.ChatRoomMemberRepository; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.support.fixture.ChatFixture; +import umc.cockple.demo.support.fixture.MemberFixture; +import umc.cockple.demo.support.fixture.PartyFixture; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +@ExtendWith(MockitoExtension.class) +@DisplayName("DirectChatRoomActivationService") +class DirectChatRoomActivationServiceTest { + + @Mock private ChatMessageRepository chatMessageRepository; + @Mock private ChatRoomMemberRepository chatRoomMemberRepository; + + private DirectChatRoomActivationService directChatRoomActivationService; + + @BeforeEach + void setUp() { + directChatRoomActivationService = + new DirectChatRoomActivationService(chatMessageRepository, chatRoomMemberRepository); + } + + @Test + @DisplayName("개인 채팅방 첫 메시지면 대기 중인 상대 멤버를 JOINED로 변경한다") + void joinPendingMemberOnFirstMessage_joinsPendingMember() { + // given + Long chatRoomId = 10L; + Long senderId = 101L; + Member receiver = MemberFixture.createMemberWithName("김철수", "철수", Gender.MALE, Level.B, 2002L); + ReflectionTestUtils.setField(receiver, "id", 102L); + + ChatRoom chatRoom = ChatFixture.createDirectChatRoom(); + ReflectionTestUtils.setField(chatRoom, "id", chatRoomId); + + ChatRoomMember pendingMember = ChatRoomMember.createPending(chatRoom, receiver, "철수"); + + given(chatMessageRepository.countByChatRoomId(chatRoomId)).willReturn(1); + given(chatRoomMemberRepository.findPendingMemberInDirect(chatRoomId, senderId)) + .willReturn(Optional.of(pendingMember)); + + // when + directChatRoomActivationService.joinPendingMemberOnFirstMessage(chatRoom, senderId); + + // then + assertThat(pendingMember.getStatus()).isEqualTo(ChatRoomMemberStatus.JOINED); + } + + @Test + @DisplayName("모임 채팅방이면 첫 메시지 여부를 조회하지 않는다") + void joinPendingMemberOnFirstMessage_ignoresPartyChatRoom() { + // given + Long senderId = 101L; + ChatRoom chatRoom = ChatFixture.createPartyChatRoom( + PartyFixture.createParty("모임", senderId, PartyFixture.createPartyAddr("서울", "강남구")) + ); + ReflectionTestUtils.setField(chatRoom, "id", 10L); + + // when + directChatRoomActivationService.joinPendingMemberOnFirstMessage(chatRoom, senderId); + + // then + then(chatMessageRepository).shouldHaveNoInteractions(); + then(chatRoomMemberRepository).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("개인 채팅방이어도 첫 메시지가 아니면 대기 멤버를 조회하지 않는다") + void joinPendingMemberOnFirstMessage_ignoresNonFirstMessage() { + // given + Long chatRoomId = 10L; + Long senderId = 101L; + ChatRoom chatRoom = ChatFixture.createDirectChatRoom(); + ReflectionTestUtils.setField(chatRoom, "id", chatRoomId); + + given(chatMessageRepository.countByChatRoomId(chatRoomId)).willReturn(2); + + // when + directChatRoomActivationService.joinPendingMemberOnFirstMessage(chatRoom, senderId); + + // then + then(chatRoomMemberRepository).shouldHaveNoInteractions(); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/chat/service/websocket/send/support/SentMessageReadStatusServiceTest.java b/src/test/java/umc/cockple/demo/domain/chat/service/websocket/send/support/SentMessageReadStatusServiceTest.java new file mode 100644 index 000000000..b14470022 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/chat/service/websocket/send/support/SentMessageReadStatusServiceTest.java @@ -0,0 +1,106 @@ +package umc.cockple.demo.domain.chat.service.websocket.send.support; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import umc.cockple.demo.domain.chat.service.support.reader.ReadStatusReader; +import umc.cockple.demo.domain.chat.service.support.updater.ChatMemberReadStateUpdater; +import umc.cockple.demo.domain.chat.service.support.updater.ReadStatusUpdater; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +@DisplayName("SentMessageReadStatusService") +class SentMessageReadStatusServiceTest { + + @Mock private ReadStatusReader readStatusReader; + @Mock private ReadStatusUpdater readStatusUpdater; + @Mock private ChatMemberReadStateUpdater chatMemberReadStateUpdater; + + private SentMessageReadStatusService sentMessageReadStatusService; + + @BeforeEach + void setUp() { + sentMessageReadStatusService = + new SentMessageReadStatusService(readStatusReader, readStatusUpdater, chatMemberReadStateUpdater); + } + + @Test + @DisplayName("활성 구독자 중 발신자를 제외하고 읽음 처리와 lastReadMessageId 갱신을 배치 처리한다") + void markActiveSubscribersAsRead_excludesSenderAndUpdatesReadersInBatch() { + // given + Long chatRoomId = 10L; + Long messageId = 100L; + Long senderId = 1L; + List activeSubscribers = List.of(senderId, 2L, 3L); + List readers = List.of(2L, 3L); + + given(readStatusUpdater.markMessageAsReadForMembers(messageId, readers)).willReturn(2); + given(chatMemberReadStateUpdater.advanceLastReadMessageIdForMembers(chatRoomId, readers, messageId)) + .willReturn(2); + given(readStatusReader.countUnreadByMessageId(messageId)).willReturn(1); + + // when + int unreadCount = sentMessageReadStatusService.markActiveSubscribersAsRead( + chatRoomId, messageId, activeSubscribers, senderId); + + // then + assertThat(unreadCount).isEqualTo(1); + then(readStatusUpdater).should().markMessageAsReadForMembers(messageId, readers); + then(chatMemberReadStateUpdater).should().advanceLastReadMessageIdForMembers(chatRoomId, readers, messageId); + } + + @Test + @DisplayName("시스템 메시지는 활성 구독자 전체를 읽음 처리와 lastReadMessageId 갱신 대상으로 삼는다") + void markActiveSubscribersAsRead_includesAllSubscribersForSystemMessage() { + // given + Long chatRoomId = 10L; + Long messageId = 100L; + List activeSubscribers = List.of(1L, 2L); + + given(readStatusUpdater.markMessageAsReadForMembers(messageId, activeSubscribers)).willReturn(2); + given(chatMemberReadStateUpdater.advanceLastReadMessageIdForMembers(chatRoomId, activeSubscribers, messageId)) + .willReturn(2); + given(readStatusReader.countUnreadByMessageId(messageId)).willReturn(0); + + // when + int unreadCount = sentMessageReadStatusService.markActiveSubscribersAsRead( + chatRoomId, messageId, activeSubscribers, null); + + // then + assertThat(unreadCount).isZero(); + then(readStatusUpdater).should().markMessageAsReadForMembers(messageId, activeSubscribers); + then(chatMemberReadStateUpdater).should() + .advanceLastReadMessageIdForMembers(chatRoomId, activeSubscribers, messageId); + } + + @Test + @DisplayName("발신자만 활성 구독 중이면 읽음 처리와 lastReadMessageId 갱신 없이 최종 안읽음 수만 조회한다") + void markActiveSubscribersAsRead_skipsReadUpdatesWhenOnlySenderIsActive() { + // given + Long chatRoomId = 10L; + Long messageId = 100L; + Long senderId = 1L; + given(readStatusReader.countUnreadByMessageId(messageId)).willReturn(2); + + // when + int unreadCount = sentMessageReadStatusService.markActiveSubscribersAsRead( + chatRoomId, messageId, List.of(senderId), senderId); + + // then + assertThat(unreadCount).isEqualTo(2); + then(readStatusUpdater).should(never()).markMessageAsReadForMembers(anyLong(), anyList()); + then(chatMemberReadStateUpdater).should(never()) + .advanceLastReadMessageIdForMembers(anyLong(), anyList(), anyLong()); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/chat/service/websocket/subscription/ChatRoomSubscriptionServiceTest.java b/src/test/java/umc/cockple/demo/domain/chat/service/websocket/subscription/ChatRoomSubscriptionServiceTest.java new file mode 100644 index 000000000..475c79712 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/chat/service/websocket/subscription/ChatRoomSubscriptionServiceTest.java @@ -0,0 +1,95 @@ +package umc.cockple.demo.domain.chat.service.websocket.subscription; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import umc.cockple.demo.domain.chat.repository.redis.ChatRoomSubscriptionStore; +import umc.cockple.demo.domain.chat.service.websocket.UnreadCountUpdate; +import umc.cockple.demo.domain.chat.service.websocket.broadcast.UnreadCountUpdateBroadcaster; +import umc.cockple.demo.domain.chat.service.websocket.subscription.support.ActiveChatRoomSubscriberReader; +import umc.cockple.demo.domain.chat.service.websocket.subscription.support.SubscribeReadStatusService; + +import java.util.List; + +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ChatRoomSubscriptionService") +class ChatRoomSubscriptionServiceTest { + + @Mock private SubscribeReadStatusService subscribeReadStatusService; + @Mock private ChatRoomSubscriptionStore chatRoomSubscriptionStore; + @Mock private UnreadCountUpdateBroadcaster unreadCountUpdateBroadcaster; + @Mock private ActiveChatRoomSubscriberReader activeChatRoomSubscriberReader; + + private ChatRoomSubscriptionService chatRoomSubscriptionService; + + @BeforeEach + void setUp() { + chatRoomSubscriptionService = new ChatRoomSubscriptionService( + subscribeReadStatusService, + chatRoomSubscriptionStore, + unreadCountUpdateBroadcaster, + activeChatRoomSubscriberReader + ); + } + + @Test + @DisplayName("구독 시 읽음 처리 결과가 있으면 안읽은 수 업데이트 broadcaster에 위임한다") + void subscribeToChatRoom_delegatesUnreadCountUpdatesToBroadcaster() { + // given + Long chatRoomId = 1L; + Long memberId = 10L; + List activeSubscribers = List.of(memberId, 20L); + List updates = + List.of(new UnreadCountUpdate(100L, 1)); + + given(subscribeReadStatusService.markUnreadMessagesAsReadOnSubscribe(chatRoomId, memberId)) + .willReturn(updates); + given(activeChatRoomSubscriberReader.findActiveSubscribers(chatRoomId)).willReturn(activeSubscribers); + + // when + chatRoomSubscriptionService.subscribeToChatRoom(chatRoomId, memberId); + + // then + then(chatRoomSubscriptionStore).should().addSubscriber(chatRoomId, memberId); + then(unreadCountUpdateBroadcaster).should() + .broadcast(chatRoomId, updates, activeSubscribers, memberId); + } + + @Test + @DisplayName("구독 시 읽음 처리 결과가 없으면 안읽은 수 업데이트를 보내지 않는다") + void subscribeToChatRoom_doesNotBroadcastWhenNoUnreadUpdates() { + // given + Long chatRoomId = 1L; + Long memberId = 10L; + given(subscribeReadStatusService.markUnreadMessagesAsReadOnSubscribe(chatRoomId, memberId)) + .willReturn(List.of()); + + // when + chatRoomSubscriptionService.subscribeToChatRoom(chatRoomId, memberId); + + // then + then(chatRoomSubscriptionStore).should().addSubscriber(chatRoomId, memberId); + then(activeChatRoomSubscriberReader).shouldHaveNoInteractions(); + then(unreadCountUpdateBroadcaster).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("구독 해제는 채팅방 구독 저장소에 위임한다") + void unsubscribeToChatRoom_removesSubscriber() { + // given + Long chatRoomId = 1L; + Long memberId = 10L; + + // when + chatRoomSubscriptionService.unsubscribeToChatRoom(chatRoomId, memberId); + + // then + then(chatRoomSubscriptionStore).should().removeSubscriber(chatRoomId, memberId); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/chat/service/websocket/subscription/support/ActiveChatRoomSubscriberReaderTest.java b/src/test/java/umc/cockple/demo/domain/chat/service/websocket/subscription/support/ActiveChatRoomSubscriberReaderTest.java new file mode 100644 index 000000000..2b82fe7ed --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/chat/service/websocket/subscription/support/ActiveChatRoomSubscriberReaderTest.java @@ -0,0 +1,50 @@ +package umc.cockple.demo.domain.chat.service.websocket.subscription.support; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import umc.cockple.demo.domain.chat.repository.redis.ChatRoomSubscriptionStore; +import umc.cockple.demo.domain.chat.service.websocket.session.ChatSessionRegistry; + +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ActiveChatRoomSubscriberReader") +class ActiveChatRoomSubscriberReaderTest { + + @Mock private ChatRoomSubscriptionStore chatRoomSubscriptionStore; + @Mock private ChatSessionRegistry sessionRegistry; + + private ActiveChatRoomSubscriberReader activeChatRoomSubscriberReader; + + @BeforeEach + void setUp() { + activeChatRoomSubscriberReader = new ActiveChatRoomSubscriberReader( + chatRoomSubscriptionStore, + sessionRegistry + ); + } + + @Test + @DisplayName("Redis 구독자 중 열린 세션 멤버만 활성 구독자로 반환한다") + void findActiveSubscribers_returnsOpenSubscribers() { + // given + Long chatRoomId = 1L; + Set subscribedMemberIds = Set.of(10L, 20L); + given(chatRoomSubscriptionStore.getSubscribers(chatRoomId)).willReturn(subscribedMemberIds); + given(sessionRegistry.findOpenMemberIds(subscribedMemberIds)).willReturn(List.of(10L)); + + // when + List activeSubscribers = activeChatRoomSubscriberReader.findActiveSubscribers(chatRoomId); + + // then + assertThat(activeSubscribers).containsExactly(10L); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/chat/service/websocket/subscription/support/SubscribeReadStatusServiceTest.java b/src/test/java/umc/cockple/demo/domain/chat/service/websocket/subscription/support/SubscribeReadStatusServiceTest.java new file mode 100644 index 000000000..7d4e2658c --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/chat/service/websocket/subscription/support/SubscribeReadStatusServiceTest.java @@ -0,0 +1,169 @@ +package umc.cockple.demo.domain.chat.service.websocket.subscription.support; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import umc.cockple.demo.domain.chat.events.ChatUnreadStatusUpdateEvent; +import umc.cockple.demo.domain.chat.service.support.reader.ReadStatusReader; +import umc.cockple.demo.domain.chat.service.support.updater.ChatMemberReadStateUpdater; +import umc.cockple.demo.domain.chat.service.support.updater.ReadStatusUpdater; +import umc.cockple.demo.domain.chat.service.websocket.UnreadCountUpdate; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +@DisplayName("SubscribeReadStatusService") +class SubscribeReadStatusServiceTest { + + @Mock private ReadStatusReader readStatusReader; + @Mock private ReadStatusUpdater readStatusUpdater; + @Mock private ChatMemberReadStateUpdater chatMemberReadStateUpdater; + @Mock private ApplicationEventPublisher eventPublisher; + + private SubscribeReadStatusService subscribeReadStatusService; + + @BeforeEach + void setUp() { + subscribeReadStatusService = new SubscribeReadStatusService( + readStatusReader, + readStatusUpdater, + chatMemberReadStateUpdater, + eventPublisher + ); + } + + @Test + @DisplayName("구독 시 unread 메시지를 읽음 처리하면 안읽음 상태 업데이트 이벤트를 발행한다") + void markUnreadMessagesAsReadOnSubscribe_publishesUnreadStatusUpdateEvent() { + // given + Long chatRoomId = 10L; + Long memberId = 101L; + Long firstMessageId = 201L; + Long secondMessageId = 202L; + + given(readStatusReader.findUnreadMessageIds(chatRoomId, memberId)) + .willReturn(List.of(firstMessageId, secondMessageId)); + given(readStatusUpdater.markMessagesAsReadForMember( + chatRoomId, memberId, List.of(firstMessageId, secondMessageId))).willReturn(2); + given(readStatusReader.countUnreadByMessageIdsAsSparseMap(List.of(firstMessageId, secondMessageId))) + .willReturn(Map.of(firstMessageId, 2, secondMessageId, 1)); + given(chatMemberReadStateUpdater.advanceLastReadMessageId(chatRoomId, memberId, secondMessageId)) + .willReturn(1); + + // when + List updates = + subscribeReadStatusService.markUnreadMessagesAsReadOnSubscribe(chatRoomId, memberId); + + // then + assertThat(updates) + .extracting(UnreadCountUpdate::messageId) + .containsExactly(firstMessageId, secondMessageId); + assertThat(updates) + .extracting(UnreadCountUpdate::newUnreadCount) + .containsExactly(2, 1); + then(readStatusUpdater).should() + .markMessagesAsReadForMember(chatRoomId, memberId, List.of(firstMessageId, secondMessageId)); + then(readStatusReader).should() + .countUnreadByMessageIdsAsSparseMap(List.of(firstMessageId, secondMessageId)); + then(chatMemberReadStateUpdater).should() + .advanceLastReadMessageId(chatRoomId, memberId, secondMessageId); + + ArgumentCaptor eventCaptor = + ArgumentCaptor.forClass(ChatUnreadStatusUpdateEvent.class); + then(eventPublisher).should().publishEvent(eventCaptor.capture()); + assertThat(eventCaptor.getValue().targetMemberIds()).containsExactly(memberId); + } + + @Test + @DisplayName("구독 시 unread 메시지가 없으면 안읽음 상태 업데이트 이벤트를 발행하지 않는다") + void markUnreadMessagesAsReadOnSubscribe_doesNotPublishEventWhenNoUnreadMessages() { + // given + Long chatRoomId = 10L; + Long memberId = 101L; + given(readStatusReader.findUnreadMessageIds(chatRoomId, memberId)) + .willReturn(List.of()); + + // when + List updates = + subscribeReadStatusService.markUnreadMessagesAsReadOnSubscribe(chatRoomId, memberId); + + // then + assertThat(updates).isEmpty(); + then(readStatusUpdater).should(never()) + .markMessagesAsReadForMember(anyLong(), anyLong(), anyList()); + then(readStatusReader).should(never()).countUnreadByMessageIdsAsSparseMap(anyList()); + then(chatMemberReadStateUpdater).should(never()).advanceLastReadMessageId(anyLong(), anyLong(), anyLong()); + then(eventPublisher).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("구독 시 메시지별 안읽음 수 결과가 누락되면 0으로 반환한다") + void markUnreadMessagesAsReadOnSubscribe_fillsMissingUnreadCountWithZero() { + // given + Long chatRoomId = 10L; + Long memberId = 101L; + Long firstMessageId = 201L; + Long secondMessageId = 202L; + + given(readStatusReader.findUnreadMessageIds(chatRoomId, memberId)) + .willReturn(List.of(firstMessageId, secondMessageId)); + given(readStatusUpdater.markMessagesAsReadForMember( + chatRoomId, memberId, List.of(firstMessageId, secondMessageId))).willReturn(2); + given(readStatusReader.countUnreadByMessageIdsAsSparseMap(List.of(firstMessageId, secondMessageId))) + .willReturn(Map.of(firstMessageId, 2)); + given(chatMemberReadStateUpdater.advanceLastReadMessageId(chatRoomId, memberId, secondMessageId)) + .willReturn(1); + + // when + List updates = + subscribeReadStatusService.markUnreadMessagesAsReadOnSubscribe(chatRoomId, memberId); + + // then + assertThat(updates) + .extracting(UnreadCountUpdate::messageId) + .containsExactly(firstMessageId, secondMessageId); + assertThat(updates) + .extracting(UnreadCountUpdate::newUnreadCount) + .containsExactly(2, 0); + } + + @Test + @DisplayName("구독 시 unread 메시지 ID 목록이 정렬되지 않아도 가장 큰 메시지 ID로 lastReadMessageId를 갱신한다") + void markUnreadMessagesAsReadOnSubscribe_advancesLastReadToMaxMessageId() { + // given + Long chatRoomId = 10L; + Long memberId = 101L; + Long latestMessageId = 202L; + Long olderMessageId = 201L; + List unorderedMessageIds = List.of(latestMessageId, olderMessageId); + + given(readStatusReader.findUnreadMessageIds(chatRoomId, memberId)) + .willReturn(unorderedMessageIds); + given(readStatusUpdater.markMessagesAsReadForMember(chatRoomId, memberId, unorderedMessageIds)) + .willReturn(2); + given(readStatusReader.countUnreadByMessageIdsAsSparseMap(unorderedMessageIds)) + .willReturn(Map.of(latestMessageId, 1, olderMessageId, 1)); + given(chatMemberReadStateUpdater.advanceLastReadMessageId(chatRoomId, memberId, latestMessageId)) + .willReturn(1); + + // when + subscribeReadStatusService.markUnreadMessagesAsReadOnSubscribe(chatRoomId, memberId); + + // then + then(chatMemberReadStateUpdater).should() + .advanceLastReadMessageId(chatRoomId, memberId, latestMessageId); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/contest/integration/ContestMedalSummaryQueryCountTest.java b/src/test/java/umc/cockple/demo/domain/contest/integration/ContestMedalSummaryQueryCountTest.java new file mode 100644 index 000000000..57e4f94f7 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/contest/integration/ContestMedalSummaryQueryCountTest.java @@ -0,0 +1,98 @@ +package umc.cockple.demo.domain.contest.integration; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +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 umc.cockple.demo.domain.contest.dto.ContestMedalSummaryDTO; +import umc.cockple.demo.domain.contest.enums.MedalType; +import umc.cockple.demo.domain.contest.repository.ContestRepository; +import umc.cockple.demo.domain.contest.service.ContestQueryService; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.support.IntegrationTestBase; +import umc.cockple.demo.support.fixture.ContestFixture; +import umc.cockple.demo.support.fixture.MemberFixture; + +import static org.assertj.core.api.Assertions.assertThat; +import static umc.cockple.demo.support.QueryCountAssert.assertQueryCount; + +// 메달 개수 조회 쿼리 최적화(GOLD/SILVER/BRONZE 3회 → 조건부 집계 1회) 회귀 방지 테스트 +@DisplayName("메달 개수 조회 쿼리 카운트 테스트") +class ContestMedalSummaryQueryCountTest extends IntegrationTestBase { + + /** + * 메달 개수 조회의 기대 쿼리 수 + * 종류별로 나누지 않고 조건부 집계로 한 번에 세므로, 메달 개수와 무관하게 1이어야 한다. + */ + private static final int MEDAL_SUMMARY_QUERY_COUNT = 1; + + @Autowired ContestQueryService contestQueryService; + @Autowired MemberRepository memberRepository; + @Autowired ContestRepository contestRepository; + + @PersistenceContext EntityManager em; + + @AfterEach + void tearDown() { + contestRepository.deleteAll(); + memberRepository.deleteAll(); + } + + @Test + @DisplayName("메달 개수와 무관하게 항상 1개의 쿼리만 실행한다") + void getMyMedalSummary_runsSingleQuery() { + Member member = createMember(8001L); + // GOLD 2, SILVER 1, BRONZE 1, NONE 1 + seed(member, MedalType.GOLD, MedalType.GOLD, MedalType.SILVER, MedalType.BRONZE, MedalType.NONE); + + assertQueryCount(em, MEDAL_SUMMARY_QUERY_COUNT, () -> + contestQueryService.getMyMedalSummary(member.getId())); + } + + @Test + @DisplayName("조건부 집계 결과가 종류별 개수·합계와 일치한다") + void getMyMedalSummary_returnsCorrectCounts() { + Member member = createMember(8002L); + seed(member, MedalType.GOLD, MedalType.GOLD, MedalType.SILVER, MedalType.BRONZE, MedalType.NONE); + + ContestMedalSummaryDTO.Response response = contestQueryService.getMyMedalSummary(member.getId()); + + assertThat(response.goldCount()).isEqualTo(2); + assertThat(response.silverCount()).isEqualTo(1); + assertThat(response.bronzeCount()).isEqualTo(1); + assertThat(response.myMedalTotal()).isEqualTo(4); // NONE은 합계에 미포함 + } + + @Test + @DisplayName("메달이 없으면 모두 0을 반환한다") + void getMyMedalSummary_noMedals() { + Member member = createMember(8003L); + + ContestMedalSummaryDTO.Response response = contestQueryService.getMyMedalSummary(member.getId()); + + assertThat(response.goldCount()).isZero(); + assertThat(response.silverCount()).isZero(); + assertThat(response.bronzeCount()).isZero(); + assertThat(response.myMedalTotal()).isZero(); + } + + // === 시딩 === + + private Member createMember(long socialId) { + return memberRepository.save( + MemberFixture.createMember("회원" + socialId, Gender.MALE, Level.A, socialId)); + } + + private void seed(Member member, MedalType... medalTypes) { + int idx = 0; + for (MedalType medalType : medalTypes) { + contestRepository.save( + ContestFixture.createContest(member, "대회" + member.getId() + "_" + idx++, medalType)); + } + } +} diff --git a/src/test/java/umc/cockple/demo/domain/contest/service/ContestQueryServiceTest.java b/src/test/java/umc/cockple/demo/domain/contest/service/ContestQueryServiceTest.java index 3ef7d5fdc..8e205a925 100644 --- a/src/test/java/umc/cockple/demo/domain/contest/service/ContestQueryServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/contest/service/ContestQueryServiceTest.java @@ -19,6 +19,7 @@ import umc.cockple.demo.domain.contest.exception.ContestErrorCode; import umc.cockple.demo.domain.contest.exception.ContestException; import umc.cockple.demo.domain.contest.repository.ContestRepository; +import umc.cockple.demo.domain.contest.repository.MedalCountProjection; import umc.cockple.demo.domain.file.service.FileService; import umc.cockple.demo.domain.member.domain.Member; import umc.cockple.demo.domain.party.enums.ParticipationType; @@ -36,6 +37,7 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; @ExtendWith(MockitoExtension.class) @DisplayName("ContestQueryService") @@ -388,9 +390,11 @@ void getMedalSummary_hasMedals() { .bronzeCount(1) .build(); - given(contestRepository.countGoldMedalsByMemberId(member.getId())).willReturn(2); - given(contestRepository.countSilverMedalsByMemberId(member.getId())).willReturn(1); - given(contestRepository.countBronzeMedalsByMemberId(member.getId())).willReturn(1); + MedalCountProjection counts = mock(MedalCountProjection.class); + given(counts.getGold()).willReturn(2L); + given(counts.getSilver()).willReturn(1L); + given(counts.getBronze()).willReturn(1L); + given(contestRepository.countMedalsByMemberId(member.getId())).willReturn(counts); given(contestConverter.toMedalSummaryResponseDTO(2, 1, 1)).willReturn(expectedResponse); // when @@ -415,9 +419,11 @@ void getMedalSummary_noMedals() { .bronzeCount(0) .build(); - given(contestRepository.countGoldMedalsByMemberId(member.getId())).willReturn(0); - given(contestRepository.countSilverMedalsByMemberId(member.getId())).willReturn(0); - given(contestRepository.countBronzeMedalsByMemberId(member.getId())).willReturn(0); + MedalCountProjection counts = mock(MedalCountProjection.class); + given(counts.getGold()).willReturn(0L); + given(counts.getSilver()).willReturn(0L); + given(counts.getBronze()).willReturn(0L); + given(contestRepository.countMedalsByMemberId(member.getId())).willReturn(counts); given(contestConverter.toMedalSummaryResponseDTO(0, 0, 0)).willReturn(expectedResponse); // when diff --git a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseGuestIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseGuestIntegrationTest.java index 97bf556b9..86ca5b578 100644 --- a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseGuestIntegrationTest.java +++ b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseGuestIntegrationTest.java @@ -16,6 +16,7 @@ import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; import umc.cockple.demo.domain.exercise.repository.GuestRepository; import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.exception.MemberErrorCode; import umc.cockple.demo.domain.member.repository.MemberExerciseRepository; import umc.cockple.demo.domain.member.repository.MemberPartyRepository; import umc.cockple.demo.domain.member.repository.MemberRepository; @@ -398,8 +399,8 @@ class Failure { mockMvc.perform(get("/api/exercises/{exerciseId}/guests", exercise.getId())) .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.code").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getCode())) - .andExpect(jsonPath("$.message").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getMessage())); + .andExpect(jsonPath("$.code").value(MemberErrorCode.MEMBER_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(MemberErrorCode.MEMBER_NOT_FOUND.getMessage())); } } } diff --git a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseLifecycleIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseLifecycleIntegrationTest.java index e7e10077a..6b6713d78 100644 --- a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseLifecycleIntegrationTest.java +++ b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseLifecycleIntegrationTest.java @@ -17,6 +17,7 @@ import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; import umc.cockple.demo.domain.exercise.repository.GuestRepository; import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.exception.MemberErrorCode; import umc.cockple.demo.domain.member.repository.MemberExerciseRepository; import umc.cockple.demo.domain.member.repository.MemberPartyRepository; import umc.cockple.demo.domain.member.repository.MemberRepository; @@ -743,8 +744,8 @@ class Failure { mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId())) .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.code").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getCode())) - .andExpect(jsonPath("$.message").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getMessage())); + .andExpect(jsonPath("$.code").value(MemberErrorCode.MEMBER_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(MemberErrorCode.MEMBER_NOT_FOUND.getMessage())); } } } @@ -803,6 +804,17 @@ class Failure { .andExpect(jsonPath("$.code").value(ExerciseErrorCode.EXERCISE_NOT_FOUND.getCode())) .andExpect(jsonPath("$.message").value(ExerciseErrorCode.EXERCISE_NOT_FOUND.getMessage())); } + + @Test + @DisplayName("모임장이나 부모임장이 아니면 권한 없음 에러를 반환한다") + void 모임장이나_부모임장이_아니면_권한_없음_에러를_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/exercises/{exerciseId}/for-edit", exercise.getId())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.INSUFFICIENT_PERMISSION.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.INSUFFICIENT_PERMISSION.getMessage())); + } } } diff --git a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseMapIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseMapIntegrationTest.java index 3009b446d..7dce25ea9 100644 --- a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseMapIntegrationTest.java +++ b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseMapIntegrationTest.java @@ -12,6 +12,7 @@ import umc.cockple.demo.domain.exercise.repository.GuestRepository; import umc.cockple.demo.domain.member.domain.Member; import umc.cockple.demo.domain.member.domain.MemberAddr; +import umc.cockple.demo.domain.member.exception.MemberErrorCode; import umc.cockple.demo.domain.member.repository.MemberAddrRepository; import umc.cockple.demo.domain.member.repository.MemberExerciseRepository; import umc.cockple.demo.domain.member.repository.MemberPartyRepository; @@ -162,19 +163,6 @@ class Success { @DisplayName("실패 케이스") class Failure { - @Test - @DisplayName("존재하지 않는 멤버면 에러를 반환한다") - void 존재하지_않는_멤버면_에러를_반환한다() throws Exception { - SecurityContextHelper.setAuthentication(999L, "없는멤버"); - - mockMvc.perform(get("/api/buildings/exercises/{date}", targetDate) - .param("buildingName", targetBuildingName) - .param("streetAddr", targetStreetAddr)) - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.code").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getCode())) - .andExpect(jsonPath("$.message").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getMessage())); - } - @Test @DisplayName("buildingName이 없으면 400을 반환한다") void buildingName이_없으면_400을_반환한다() throws Exception { @@ -381,8 +369,8 @@ class Failure { mockMvc.perform(get("/api/buildings/map/monthly") .param("date", targetDate.toString())) .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.code").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getCode())) - .andExpect(jsonPath("$.message").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getMessage())); + .andExpect(jsonPath("$.code").value(MemberErrorCode.MEMBER_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(MemberErrorCode.MEMBER_NOT_FOUND.getMessage())); } @Test @@ -393,8 +381,8 @@ class Failure { mockMvc.perform(get("/api/buildings/map/monthly") .param("date", targetDate.toString())) .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value(ExerciseErrorCode.MAIN_ADDRESS_NULL.getCode())) - .andExpect(jsonPath("$.message").value(ExerciseErrorCode.MAIN_ADDRESS_NULL.getMessage())); + .andExpect(jsonPath("$.code").value(MemberErrorCode.MAIN_ADDRESS_NULL.getCode())) + .andExpect(jsonPath("$.message").value(MemberErrorCode.MAIN_ADDRESS_NULL.getMessage())); } @Test @@ -407,8 +395,8 @@ class Failure { .param("latitude", "37.5") .param("longitude", "127.0")) .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value(ExerciseErrorCode.MAIN_ADDRESS_NULL.getCode())) - .andExpect(jsonPath("$.message").value(ExerciseErrorCode.MAIN_ADDRESS_NULL.getMessage())); + .andExpect(jsonPath("$.code").value(MemberErrorCode.MAIN_ADDRESS_NULL.getCode())) + .andExpect(jsonPath("$.message").value(MemberErrorCode.MAIN_ADDRESS_NULL.getMessage())); } @Test diff --git a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseMyIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseMyIntegrationTest.java index d3817272f..3e9e4acad 100644 --- a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseMyIntegrationTest.java +++ b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseMyIntegrationTest.java @@ -213,7 +213,7 @@ class Success { Exercise pastExercise = exerciseRepository.save( ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().minusDays(1))); - Exercise startedTodayExercise = ExerciseFixture.createExerciseWithAddr(party, LocalDate.now()); + Exercise startedTodayExercise = ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().minusDays(1)); ReflectionTestUtils.setField(startedTodayExercise, "startTime", LocalTime.now().minusMinutes(30)); startedTodayExercise = exerciseRepository.save(startedTodayExercise); Exercise firstExercise = exerciseRepository.save( @@ -260,22 +260,6 @@ class Success { .andExpect(jsonPath("$.data.exercises").isEmpty()); } } - - @Nested - @DisplayName("실패 케이스") - class Failure { - - @Test - @DisplayName("존재하지 않는 멤버면 에러를 반환한다") - void 존재하지_않는_멤버면_에러를_반환한다() throws Exception { - SecurityContextHelper.setAuthentication(999L, "없는멤버"); - - mockMvc.perform(get("/api/exercises/parties/my")) - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.code").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getCode())) - .andExpect(jsonPath("$.message").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getMessage())); - } - } } @Nested @@ -387,24 +371,6 @@ class Success { .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].exerciseId").value(exercise.getId())); } } - - @Nested - @DisplayName("실패 케이스") - class Failure { - - @Test - @DisplayName("존재하지 않는 멤버면 에러를 반환한다") - void 존재하지_않는_멤버면_에러를_반환한다() throws Exception { - SecurityContextHelper.setAuthentication(999L, "없는멤버"); - - mockMvc.perform(get("/api/exercises/parties/my/calendar") - .param("startDate", startDate.toString()) - .param("endDate", endDate.toString())) - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.code").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getCode())) - .andExpect(jsonPath("$.message").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getMessage())); - } - } } @Nested @@ -585,17 +551,6 @@ class Success { @DisplayName("실패 케이스") class Failure { - @Test - @DisplayName("존재하지 않는 멤버면 에러를 반환한다") - void 존재하지_않는_멤버면_에러를_반환한다() throws Exception { - SecurityContextHelper.setAuthentication(999L, "없는멤버"); - - mockMvc.perform(get("/api/exercises/my")) - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.code").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getCode())) - .andExpect(jsonPath("$.message").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getMessage())); - } - @Test @DisplayName("잘못된 필터 타입이면 400을 반환한다") void 잘못된_필터_타입이면_400을_반환한다() throws Exception { diff --git a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseRecommendationIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseRecommendationIntegrationTest.java index b2ed53bc0..11f29eddd 100644 --- a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseRecommendationIntegrationTest.java +++ b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseRecommendationIntegrationTest.java @@ -7,10 +7,10 @@ import umc.cockple.demo.domain.bookmark.domain.ExerciseBookmark; import umc.cockple.demo.domain.bookmark.repository.ExerciseBookmarkRepository; import umc.cockple.demo.domain.exercise.domain.Exercise; -import umc.cockple.demo.domain.exercise.exception.ExerciseErrorCode; import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; import umc.cockple.demo.domain.exercise.repository.GuestRepository; import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.exception.MemberErrorCode; import umc.cockple.demo.domain.member.repository.MemberAddrRepository; import umc.cockple.demo.domain.member.repository.MemberExerciseRepository; import umc.cockple.demo.domain.member.repository.MemberPartyRepository; @@ -477,8 +477,8 @@ class Failure { .param("startDate", startDate.toString()) .param("endDate", endDate.toString())) .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.code").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getCode())) - .andExpect(jsonPath("$.message").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getMessage())); + .andExpect(jsonPath("$.code").value(MemberErrorCode.MEMBER_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(MemberErrorCode.MEMBER_NOT_FOUND.getMessage())); } @Test @@ -490,8 +490,8 @@ class Failure { .param("startDate", startDate.toString()) .param("endDate", endDate.toString())) .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value(ExerciseErrorCode.MAIN_ADDRESS_NULL.getCode())) - .andExpect(jsonPath("$.message").value(ExerciseErrorCode.MAIN_ADDRESS_NULL.getMessage())); + .andExpect(jsonPath("$.code").value(MemberErrorCode.MAIN_ADDRESS_NULL.getCode())) + .andExpect(jsonPath("$.message").value(MemberErrorCode.MAIN_ADDRESS_NULL.getMessage())); } @Test diff --git a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseGuestQueryServiceTest.java b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseGuestQueryServiceTest.java new file mode 100644 index 000000000..72be7e47d --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseGuestQueryServiceTest.java @@ -0,0 +1,257 @@ +package umc.cockple.demo.domain.exercise.service; + +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.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.data.domain.Sort; +import org.springframework.test.util.ReflectionTestUtils; +import umc.cockple.demo.domain.bookmark.repository.ExerciseBookmarkRepository; +import umc.cockple.demo.domain.exercise.converter.ExerciseConverter; +import umc.cockple.demo.domain.exercise.domain.Exercise; +import umc.cockple.demo.domain.exercise.domain.Guest; +import umc.cockple.demo.domain.exercise.dto.ExerciseBuildingDetailDTO; +import umc.cockple.demo.domain.exercise.dto.ExerciseDetailDTO; +import umc.cockple.demo.domain.exercise.dto.ExerciseEditDetailDTO; +import umc.cockple.demo.domain.exercise.dto.ExerciseMapBuildingsDTO; +import umc.cockple.demo.domain.exercise.dto.ExerciseMyGuestListDTO; +import umc.cockple.demo.domain.exercise.dto.MyExerciseCalendarDTO; +import umc.cockple.demo.domain.exercise.dto.MyExerciseListDTO; +import umc.cockple.demo.domain.exercise.dto.MyPartyExerciseCalendarDTO; +import umc.cockple.demo.domain.exercise.dto.MyPartyExerciseDTO; +import umc.cockple.demo.domain.exercise.dto.PartyExerciseCalendarDTO; +import umc.cockple.demo.domain.exercise.enums.MyExerciseFilterType; +import umc.cockple.demo.domain.exercise.enums.MyExerciseOrderType; +import umc.cockple.demo.domain.exercise.enums.MyPartyExerciseOrderType; +import umc.cockple.demo.domain.exercise.exception.ExerciseErrorCode; +import umc.cockple.demo.domain.exercise.exception.ExerciseException; +import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; +import umc.cockple.demo.domain.exercise.repository.GuestRepository; +import umc.cockple.demo.domain.exercise.service.support.ExerciseParticipantInfoAssembler; +import umc.cockple.demo.domain.exercise.service.support.reader.ExerciseParticipantReader; +import umc.cockple.demo.domain.exercise.service.support.reader.ExerciseReader; +import umc.cockple.demo.domain.exercise.service.support.reader.GuestReader; +import umc.cockple.demo.domain.exercise.service.query.ExerciseGuestQueryService; +import umc.cockple.demo.domain.member.service.support.MemberLookupService; +import umc.cockple.demo.domain.party.service.support.PartyLookupService; +import umc.cockple.demo.domain.file.service.FileService; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.domain.MemberExercise; +import umc.cockple.demo.domain.member.domain.MemberParty; +import umc.cockple.demo.domain.member.exception.MemberErrorCode; +import umc.cockple.demo.domain.member.exception.MemberException; +import umc.cockple.demo.domain.member.repository.MemberExerciseRepository; +import umc.cockple.demo.domain.member.repository.MemberPartyRepository; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.domain.party.domain.Party; +import umc.cockple.demo.domain.party.enums.PartyStatus; +import umc.cockple.demo.domain.party.exception.PartyErrorCode; +import umc.cockple.demo.domain.party.exception.PartyException; +import umc.cockple.demo.domain.party.repository.PartyRepository; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.global.enums.Role; +import umc.cockple.demo.support.ExerciseCalendarTestHelper; +import umc.cockple.demo.support.fixture.ExerciseFixture; +import umc.cockple.demo.support.fixture.GuestFixture; +import umc.cockple.demo.support.fixture.MemberFixture; +import umc.cockple.demo.support.fixture.PartyFixture; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.YearMonth; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.groups.Tuple.tuple; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ExerciseGuestQueryService") +class ExerciseGuestQueryServiceTest { + + private ExerciseGuestQueryService exerciseGuestQueryService; + + @Mock private ExerciseRepository exerciseRepository; + @Mock private MemberRepository memberRepository; + @Mock private MemberPartyRepository memberPartyRepository; + @Mock private MemberExerciseRepository memberExerciseRepository; + @Mock private GuestRepository guestRepository; + @Mock private FileService fileService; + + private Member manager; + private Party party; + private Exercise exercise; + + @BeforeEach + void setUp() { + ExerciseConverter exerciseConverter = new ExerciseConverter(fileService); + ExerciseParticipantReader exerciseParticipantReader = new ExerciseParticipantReader( + memberExerciseRepository, memberPartyRepository); + GuestReader guestReader = new GuestReader(guestRepository); + MemberLookupService memberLookupService = new MemberLookupService(memberRepository); + + exerciseGuestQueryService = new ExerciseGuestQueryService( + new ExerciseReader(exerciseRepository), + guestReader, + new ExerciseParticipantInfoAssembler( + exerciseParticipantReader, + guestReader, + memberLookupService, + exerciseConverter + ), + memberLookupService, + exerciseConverter + ); + + manager = MemberFixture.createMember("모임장", Gender.MALE, Level.A, 1001L); + ReflectionTestUtils.setField(manager, "id", 1L); + + party = PartyFixture.createParty("테스트 모임", manager.getId(), + PartyFixture.createPartyAddr("서울특별시", "강남구")); + ReflectionTestUtils.setField(party, "id", 10L); + + exercise = ExerciseFixture.createExercise(party, LocalDate.now().minusDays(1)); + ReflectionTestUtils.setField(exercise, "id", 100L); + ReflectionTestUtils.setField(exercise, "exerciseAddr", ExerciseFixture.createExerciseAddr()); + } + + @Nested + @DisplayName("getMyInvitedGuests") + class GetMyInvitedGuests { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("내가_초대한_게스트만_참가번호와_대기상태와_함께_반환된다") + void 내가_초대한_게스트만_참가번호와_대기상태와_함께_반환된다() { + // given + ReflectionTestUtils.setField(exercise, "maxCapacity", 1); + + Guest myFirstGuest = GuestFixture.createGuest(exercise, manager.getId(), "내게스트1", Gender.MALE); + ReflectionTestUtils.setField(myFirstGuest, "id", 201L); + ReflectionTestUtils.setField(myFirstGuest, "createdAt", LocalDateTime.now().minusMinutes(3)); + + Guest otherInvitedGuest = GuestFixture.createGuest(exercise, 2L, "다른사람게스트", Gender.MALE); + ReflectionTestUtils.setField(otherInvitedGuest, "id", 202L); + ReflectionTestUtils.setField(otherInvitedGuest, "createdAt", LocalDateTime.now().minusMinutes(2)); + + Guest mySecondGuest = GuestFixture.createGuest(exercise, manager.getId(), "내게스트2", Gender.FEMALE); + ReflectionTestUtils.setField(mySecondGuest, "id", 203L); + ReflectionTestUtils.setField(mySecondGuest, "createdAt", LocalDateTime.now().minusMinutes(1)); + + given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) + .willReturn(Optional.of(exercise)); + given(memberRepository.findById(manager.getId())) + .willReturn(Optional.of(manager)); + given(guestRepository.findByExerciseIdAndInviterId(exercise.getId(), manager.getId())) + .willReturn(List.of(myFirstGuest, mySecondGuest)); + given(memberExerciseRepository.findByExerciseIdWithMemberAndProfile(exercise.getId())) + .willReturn(List.of()); + given(guestRepository.findByExerciseId(exercise.getId())) + .willReturn(List.of(myFirstGuest, otherInvitedGuest, mySecondGuest)); + + // when + ExerciseMyGuestListDTO.Response response = exerciseGuestQueryService.getMyInvitedGuests( + exercise.getId(), manager.getId()); + + // then + assertThat(response.totalCount()).isEqualTo(2); + assertThat(response.maleCount()).isEqualTo(1); + assertThat(response.femaleCount()).isEqualTo(1); + assertThat(response.list()) + .extracting( + ExerciseMyGuestListDTO.GuestInfo::guestId, + ExerciseMyGuestListDTO.GuestInfo::isWaiting, + ExerciseMyGuestListDTO.GuestInfo::participantNumber, + ExerciseMyGuestListDTO.GuestInfo::name, + ExerciseMyGuestListDTO.GuestInfo::gender, + ExerciseMyGuestListDTO.GuestInfo::level, + ExerciseMyGuestListDTO.GuestInfo::inviterName + ) + .containsExactly( + tuple(201L, false, 1, "내게스트1", Gender.MALE, Level.B, manager.getMemberName()), + tuple(203L, true, 2, "내게스트2", Gender.FEMALE, Level.B, manager.getMemberName()) + ); + } + + @Test + @DisplayName("초대한_게스트가_없으면_빈_응답을_반환한다") + void 초대한_게스트가_없으면_빈_응답을_반환한다() { + // given + given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) + .willReturn(Optional.of(exercise)); + given(memberRepository.findById(manager.getId())) + .willReturn(Optional.of(manager)); + given(guestRepository.findByExerciseIdAndInviterId(exercise.getId(), manager.getId())) + .willReturn(List.of()); + + // when + ExerciseMyGuestListDTO.Response response = exerciseGuestQueryService.getMyInvitedGuests( + exercise.getId(), manager.getId()); + + // then + assertThat(response.totalCount()).isZero(); + assertThat(response.maleCount()).isZero(); + assertThat(response.femaleCount()).isZero(); + assertThat(response.list()).isEmpty(); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지_않는_운동이면_예외를_던진다") + void 존재하지_않는_운동이면_예외를_던진다() { + // given + given(exerciseRepository.findExerciseWithBasicInfo(999L)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> exerciseGuestQueryService.getMyInvitedGuests(999L, manager.getId())) + .isInstanceOf(ExerciseException.class) + .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.EXERCISE_NOT_FOUND); + } + + @Test + @DisplayName("존재하지_않는_멤버면_예외를_던진다") + void 존재하지_않는_멤버면_예외를_던진다() { + // given + given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) + .willReturn(Optional.of(exercise)); + given(memberRepository.findById(999L)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> exerciseGuestQueryService.getMyInvitedGuests(exercise.getId(), 999L)) + .isInstanceOf(MemberException.class) + .hasFieldOrPropertyWithValue("code", MemberErrorCode.MEMBER_NOT_FOUND); + } + } + } + + + +} diff --git a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseLifecycleQueryServiceTest.java b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseLifecycleQueryServiceTest.java new file mode 100644 index 000000000..22ff50336 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseLifecycleQueryServiceTest.java @@ -0,0 +1,784 @@ +package umc.cockple.demo.domain.exercise.service; + +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.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.data.domain.Sort; +import org.springframework.test.util.ReflectionTestUtils; +import umc.cockple.demo.domain.bookmark.repository.ExerciseBookmarkRepository; +import umc.cockple.demo.domain.exercise.converter.ExerciseConverter; +import umc.cockple.demo.domain.exercise.domain.Exercise; +import umc.cockple.demo.domain.exercise.domain.Guest; +import umc.cockple.demo.domain.exercise.dto.ExerciseBuildingDetailDTO; +import umc.cockple.demo.domain.exercise.dto.ExerciseDetailDTO; +import umc.cockple.demo.domain.exercise.dto.ExerciseEditDetailDTO; +import umc.cockple.demo.domain.exercise.dto.ExerciseMapBuildingsDTO; +import umc.cockple.demo.domain.exercise.dto.ExerciseMyGuestListDTO; +import umc.cockple.demo.domain.exercise.dto.MyExerciseCalendarDTO; +import umc.cockple.demo.domain.exercise.dto.MyExerciseListDTO; +import umc.cockple.demo.domain.exercise.dto.MyPartyExerciseCalendarDTO; +import umc.cockple.demo.domain.exercise.dto.MyPartyExerciseDTO; +import umc.cockple.demo.domain.exercise.dto.PartyExerciseCalendarDTO; +import umc.cockple.demo.domain.exercise.enums.MyExerciseFilterType; +import umc.cockple.demo.domain.exercise.enums.MyExerciseOrderType; +import umc.cockple.demo.domain.exercise.enums.MyPartyExerciseOrderType; +import umc.cockple.demo.domain.exercise.exception.ExerciseErrorCode; +import umc.cockple.demo.domain.exercise.exception.ExerciseException; +import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; +import umc.cockple.demo.domain.exercise.repository.GuestRepository; +import umc.cockple.demo.domain.exercise.service.support.ExerciseParticipantInfoAssembler; +import umc.cockple.demo.domain.exercise.service.support.reader.ExerciseParticipantReader; +import umc.cockple.demo.domain.exercise.service.support.reader.ExerciseReader; +import umc.cockple.demo.domain.exercise.service.support.reader.GuestReader; +import umc.cockple.demo.domain.exercise.service.query.ExerciseLifecycleQueryService; +import umc.cockple.demo.domain.member.service.support.MemberLookupService; +import umc.cockple.demo.domain.party.service.support.PartyLookupService; +import umc.cockple.demo.domain.file.service.FileService; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.domain.MemberExercise; +import umc.cockple.demo.domain.member.domain.MemberParty; +import umc.cockple.demo.domain.member.exception.MemberErrorCode; +import umc.cockple.demo.domain.member.exception.MemberException; +import umc.cockple.demo.domain.member.repository.MemberExerciseRepository; +import umc.cockple.demo.domain.member.repository.MemberPartyRepository; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.domain.party.domain.Party; +import umc.cockple.demo.domain.party.enums.PartyStatus; +import umc.cockple.demo.domain.party.exception.PartyErrorCode; +import umc.cockple.demo.domain.party.exception.PartyException; +import umc.cockple.demo.domain.party.repository.PartyRepository; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.global.enums.Role; +import umc.cockple.demo.support.ExerciseCalendarTestHelper; +import umc.cockple.demo.support.fixture.ExerciseFixture; +import umc.cockple.demo.support.fixture.GuestFixture; +import umc.cockple.demo.support.fixture.MemberFixture; +import umc.cockple.demo.support.fixture.PartyFixture; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.YearMonth; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.groups.Tuple.tuple; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ExerciseLifecycleQueryService") +class ExerciseLifecycleQueryServiceTest { + + private ExerciseLifecycleQueryService exerciseLifecycleQueryService; + + @Mock private ExerciseRepository exerciseRepository; + @Mock private MemberRepository memberRepository; + @Mock private MemberPartyRepository memberPartyRepository; + @Mock private MemberExerciseRepository memberExerciseRepository; + @Mock private GuestRepository guestRepository; + @Mock private PartyRepository partyRepository; + @Mock private ExerciseBookmarkRepository exerciseBookmarkRepository; + @Mock private FileService fileService; + + private ExerciseConverter exerciseConverter; + + private Member manager; + private Party party; + private Exercise exercise; + + @BeforeEach + void setUp() { + exerciseConverter = new ExerciseConverter(fileService); + exerciseLifecycleQueryService = createExerciseLifecycleQueryService(exerciseConverter); + + manager = MemberFixture.createMember("모임장", Gender.MALE, Level.A, 1001L); + ReflectionTestUtils.setField(manager, "id", 1L); + + party = PartyFixture.createParty("테스트 모임", manager.getId(), + PartyFixture.createPartyAddr("서울특별시", "강남구")); + ReflectionTestUtils.setField(party, "id", 10L); + + exercise = ExerciseFixture.createExercise(party, LocalDate.now().minusDays(1)); + ReflectionTestUtils.setField(exercise, "id", 100L); + + ReflectionTestUtils.setField(exercise, "exerciseAddr", ExerciseFixture.createExerciseAddr()); + } + + private ExerciseLifecycleQueryService createExerciseLifecycleQueryService(ExerciseConverter exerciseConverter) { + ExerciseParticipantReader exerciseParticipantReader = new ExerciseParticipantReader( + memberExerciseRepository, memberPartyRepository); + MemberLookupService memberLookupService = new MemberLookupService(memberRepository); + + return new ExerciseLifecycleQueryService( + new ExerciseReader(exerciseRepository), + exerciseParticipantReader, + new ExerciseParticipantInfoAssembler( + exerciseParticipantReader, + new GuestReader(guestRepository), + memberLookupService, + exerciseConverter + ), + memberLookupService, + new ExerciseValidator(memberPartyRepository, memberExerciseRepository), + exerciseConverter + ); + } + + @Nested + @DisplayName("getExerciseDetail") + class GetExerciseDetail { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("모임장이면_isManager_true로_반환된다") + void 모임장이면_isManager_true로_반환된다() { + // given + given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) + .willReturn(Optional.of(exercise)); + given(memberRepository.findById(manager.getId())) + .willReturn(Optional.of(manager)); + given(memberExerciseRepository.findByExerciseIdWithMemberAndProfile(exercise.getId())) + .willReturn(List.of()); + given(guestRepository.findByExerciseId(exercise.getId())) + .willReturn(List.of()); + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole( + party.getId(), manager.getId(), Role.PARTY_MANAGER)) + .willReturn(true); + + // when + ExerciseDetailDTO.Response response = exerciseLifecycleQueryService.getExerciseDetail( + exercise.getId(), manager.getId()); + + // then + assertThat(response.isManager()).isTrue(); + } + + @Test + @DisplayName("부모임장이_조회하면_isManager_false로_반환된다") + void 부모임장이_조회하면_isManager_false로_반환된다() { + // given + Member subManager = MemberFixture.createMember("부모임장", Gender.FEMALE, Level.B, 2003L); + ReflectionTestUtils.setField(subManager, "id", 21L); + + given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) + .willReturn(Optional.of(exercise)); + given(memberRepository.findById(subManager.getId())) + .willReturn(Optional.of(subManager)); + given(memberExerciseRepository.findByExerciseIdWithMemberAndProfile(exercise.getId())) + .willReturn(List.of()); + given(guestRepository.findByExerciseId(exercise.getId())) + .willReturn(List.of()); + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole( + party.getId(), subManager.getId(), Role.PARTY_MANAGER)) + .willReturn(false); + + // when + ExerciseDetailDTO.Response response = exerciseLifecycleQueryService.getExerciseDetail( + exercise.getId(), subManager.getId()); + + // then + assertThat(response.isManager()).isFalse(); + } + + @Test + @DisplayName("모임_일반_멤버여도_isManager_false로_반환된다") + void 모임_일반_멤버여도_isManager_false로_반환된다() { + // given + Member normalMember = MemberFixture.createMember("일반멤버", Gender.FEMALE, Level.B, 2002L); + ReflectionTestUtils.setField(normalMember, "id", 2L); + + given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) + .willReturn(Optional.of(exercise)); + given(memberRepository.findById(normalMember.getId())) + .willReturn(Optional.of(normalMember)); + given(memberExerciseRepository.findByExerciseIdWithMemberAndProfile(exercise.getId())) + .willReturn(List.of()); + given(guestRepository.findByExerciseId(exercise.getId())) + .willReturn(List.of()); + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole( + party.getId(), normalMember.getId(), Role.PARTY_MANAGER)) + .willReturn(false); + + // when + ExerciseDetailDTO.Response response = exerciseLifecycleQueryService.getExerciseDetail( + exercise.getId(), normalMember.getId()); + + // then + assertThat(response.isManager()).isFalse(); + } + + @Test + @DisplayName("모임_외부_회원도_상세_조회에_성공하고_isManager_false로_반환된다") + void 모임_외부_회원도_상세_조회에_성공하고_isManager_false로_반환된다() { + // given + Member outsider = MemberFixture.createMember("외부회원", Gender.MALE, Level.C, 3003L); + ReflectionTestUtils.setField(outsider, "id", 3L); + + given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) + .willReturn(Optional.of(exercise)); + given(memberRepository.findById(outsider.getId())) + .willReturn(Optional.of(outsider)); + given(memberExerciseRepository.findByExerciseIdWithMemberAndProfile(exercise.getId())) + .willReturn(List.of()); + given(guestRepository.findByExerciseId(exercise.getId())) + .willReturn(List.of()); + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole( + party.getId(), outsider.getId(), Role.PARTY_MANAGER)) + .willReturn(false); + + // when + ExerciseDetailDTO.Response response = exerciseLifecycleQueryService.getExerciseDetail( + exercise.getId(), outsider.getId()); + + // then + assertThat(response.isManager()).isFalse(); + assertThat(response.info().buildingName()).isEqualTo("테스트 체육관"); + } + + @Test + @DisplayName("탈퇴_회원은_isWithdrawn_true로_반환된다") + void 탈퇴_회원은_isWithdrawn_true로_반환된다() { + // given + Member withdrawnMember = MemberFixture.createWithdrawnMember("탈퇴회원", "탈퇴닉네임", 9999L); + ReflectionTestUtils.setField(withdrawnMember, "id", 99L); + + MemberExercise memberExercise = MemberFixture.createMemberExercise(withdrawnMember, exercise); + + given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) + .willReturn(Optional.of(exercise)); + given(memberRepository.findById(manager.getId())) + .willReturn(Optional.of(manager)); + given(memberExerciseRepository.findByExerciseIdWithMemberAndProfile(exercise.getId())) + .willReturn(List.of(memberExercise)); + given(guestRepository.findByExerciseId(exercise.getId())) + .willReturn(List.of()); + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole( + party.getId(), manager.getId(), Role.PARTY_MANAGER)) + .willReturn(true); + given(memberPartyRepository.findMemberRolesByPartyAndMembers( + party.getId(), List.of(withdrawnMember.getId()))) + .willReturn(List.of()); + + // when + ExerciseDetailDTO.Response response = exerciseLifecycleQueryService.getExerciseDetail( + exercise.getId(), manager.getId()); + + // then + List participants = response.participants().list(); + assertThat(participants).hasSize(1); + assertThat(participants.get(0).isWithdrawn()).isTrue(); + } + + @Test + @DisplayName("활성_회원은_isWithdrawn_false로_반환된다") + void 활성_회원은_isWithdrawn_false로_반환된다() { + // given + Member activeMember = MemberFixture.createMember("활성회원", Gender.FEMALE, Level.B, 2002L); + ReflectionTestUtils.setField(activeMember, "id", 2L); + + MemberExercise memberExercise = MemberFixture.createMemberExercise(activeMember, exercise); + + MemberParty memberParty = MemberFixture.createMemberParty(party, activeMember, Role.PARTY_MEMBER); + + given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) + .willReturn(Optional.of(exercise)); + given(memberRepository.findById(manager.getId())) + .willReturn(Optional.of(manager)); + given(memberExerciseRepository.findByExerciseIdWithMemberAndProfile(exercise.getId())) + .willReturn(List.of(memberExercise)); + given(guestRepository.findByExerciseId(exercise.getId())) + .willReturn(List.of()); + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole( + party.getId(), manager.getId(), Role.PARTY_MANAGER)) + .willReturn(true); + given(memberPartyRepository.findMemberRolesByPartyAndMembers( + party.getId(), List.of(activeMember.getId()))) + .willReturn(List.of(memberParty)); + + // when + ExerciseDetailDTO.Response response = exerciseLifecycleQueryService.getExerciseDetail( + exercise.getId(), manager.getId()); + + // then + List participants = response.participants().list(); + assertThat(participants).hasSize(1); + assertThat(participants.get(0).isWithdrawn()).isFalse(); + } + + @Test + @DisplayName("게스트는_isWithdrawn_false로_반환된다") + void 게스트는_isWithdrawn_false로_반환된다() { + // given + Guest guest = GuestFixture.createGuest(exercise, manager.getId()); + ReflectionTestUtils.setField(guest, "id", 70L); + + given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) + .willReturn(Optional.of(exercise)); + given(memberRepository.findById(manager.getId())) + .willReturn(Optional.of(manager)); + given(memberExerciseRepository.findByExerciseIdWithMemberAndProfile(exercise.getId())) + .willReturn(List.of()); + given(guestRepository.findByExerciseId(exercise.getId())) + .willReturn(List.of(guest)); + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole( + party.getId(), manager.getId(), Role.PARTY_MANAGER)) + .willReturn(true); + given(memberRepository.findMemberNamesByIds(any())) + .willReturn(Map.of(manager.getId(), "모임장")); + + // when + ExerciseDetailDTO.Response response = exerciseLifecycleQueryService.getExerciseDetail( + exercise.getId(), manager.getId()); + + // then + List participants = response.participants().list(); + assertThat(participants).hasSize(1); + assertThat(participants.get(0).isWithdrawn()).isFalse(); + assertThat(participants.get(0).partyPosition()).isNull(); + } + + @Test + @DisplayName("참가자_유형별_partyPosition이_올바르게_반환된다") + void 참가자_유형별_partyPosition이_올바르게_반환된다() { + // given + Member subManager = MemberFixture.createMember("부모임장", Gender.FEMALE, Level.B, 5003L); + ReflectionTestUtils.setField(subManager, "id", 31L); + + Member normalMember = MemberFixture.createMember("일반멤버", Gender.MALE, Level.C, 5004L); + ReflectionTestUtils.setField(normalMember, "id", 32L); + + Member outsider = MemberFixture.createMember("외부회원", Gender.FEMALE, Level.B, 5005L); + ReflectionTestUtils.setField(outsider, "id", 33L); + + MemberExercise managerExercise = MemberFixture.createMemberExercise(manager, exercise); + ReflectionTestUtils.setField(managerExercise, "createdAt", LocalDateTime.now().minusMinutes(5)); + + MemberExercise subManagerExercise = MemberFixture.createMemberExercise(subManager, exercise); + ReflectionTestUtils.setField(subManagerExercise, "createdAt", LocalDateTime.now().minusMinutes(4)); + + MemberExercise normalMemberExercise = MemberFixture.createMemberExercise(normalMember, exercise); + ReflectionTestUtils.setField(normalMemberExercise, "createdAt", LocalDateTime.now().minusMinutes(3)); + + MemberExercise outsiderExercise = MemberFixture.createExternalMemberExercise(outsider, exercise); + ReflectionTestUtils.setField(outsiderExercise, "createdAt", LocalDateTime.now().minusMinutes(2)); + + Guest guest = GuestFixture.createGuest(exercise, manager.getId()); + ReflectionTestUtils.setField(guest, "id", 71L); + ReflectionTestUtils.setField(guest, "createdAt", LocalDateTime.now().minusMinutes(1)); + + MemberParty managerParty = MemberFixture.createMemberParty(party, manager, Role.PARTY_MANAGER); + MemberParty subManagerParty = MemberFixture.createMemberParty(party, subManager, Role.PARTY_SUBMANAGER); + MemberParty memberParty = MemberFixture.createMemberParty(party, normalMember, Role.PARTY_MEMBER); + + given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) + .willReturn(Optional.of(exercise)); + given(memberRepository.findById(manager.getId())) + .willReturn(Optional.of(manager)); + given(memberExerciseRepository.findByExerciseIdWithMemberAndProfile(exercise.getId())) + .willReturn(List.of(managerExercise, subManagerExercise, normalMemberExercise, outsiderExercise)); + given(guestRepository.findByExerciseId(exercise.getId())) + .willReturn(List.of(guest)); + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole( + party.getId(), manager.getId(), Role.PARTY_MANAGER)) + .willReturn(true); + given(memberPartyRepository.findMemberRolesByPartyAndMembers( + party.getId(), List.of(manager.getId(), subManager.getId(), normalMember.getId(), outsider.getId()))) + .willReturn(List.of(managerParty, subManagerParty, memberParty)); + given(memberRepository.findMemberNamesByIds(any())) + .willReturn(Map.of(manager.getId(), "모임장")); + + // when + ExerciseDetailDTO.Response response = exerciseLifecycleQueryService.getExerciseDetail( + exercise.getId(), manager.getId()); + + // then + assertThat(response.participants().list()) + .extracting( + ExerciseDetailDTO.ParticipantInfo::name, + ExerciseDetailDTO.ParticipantInfo::participantType, + ExerciseDetailDTO.ParticipantInfo::partyPosition) + .containsExactly( + tuple("모임장", "PARTY_MEMBER", "PARTY_MANAGER"), + tuple("부모임장", "PARTY_MEMBER", "PARTY_SUBMANAGER"), + tuple("일반멤버", "PARTY_MEMBER", "PARTY_MEMBER"), + tuple("외부회원", "EXTERNAL_PARTICIPANT", null), + tuple("게스트", "GUEST", null) + ); + } + + @Test + @DisplayName("정원_초과_참가자는_대기자_목록으로_반환된다") + void 정원_초과_참가자는_대기자_목록으로_반환된다() { + // given + ReflectionTestUtils.setField(exercise, "maxCapacity", 1); + + Member firstMember = MemberFixture.createMember("첫번째", Gender.MALE, Level.A, 3001L); + ReflectionTestUtils.setField(firstMember, "id", 3L); + + Member secondMember = MemberFixture.createMember("두번째", Gender.FEMALE, Level.B, 3002L); + ReflectionTestUtils.setField(secondMember, "id", 4L); + + MemberExercise first = MemberFixture.createMemberExercise(firstMember, exercise); + ReflectionTestUtils.setField(first, "createdAt", LocalDateTime.now().minusMinutes(10)); + + MemberExercise second = MemberFixture.createMemberExercise(secondMember, exercise); + ReflectionTestUtils.setField(second, "createdAt", LocalDateTime.now()); + + MemberParty firstParty = MemberFixture.createMemberParty(party, firstMember, Role.PARTY_MEMBER); + MemberParty secondParty = MemberFixture.createMemberParty(party, secondMember, Role.PARTY_MEMBER); + + given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) + .willReturn(Optional.of(exercise)); + given(memberRepository.findById(manager.getId())) + .willReturn(Optional.of(manager)); + given(memberExerciseRepository.findByExerciseIdWithMemberAndProfile(exercise.getId())) + .willReturn(List.of(first, second)); + given(guestRepository.findByExerciseId(exercise.getId())) + .willReturn(List.of()); + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole( + party.getId(), manager.getId(), Role.PARTY_MANAGER)) + .willReturn(true); + given(memberPartyRepository.findMemberRolesByPartyAndMembers( + party.getId(), List.of(firstMember.getId(), secondMember.getId()))) + .willReturn(List.of(firstParty, secondParty)); + + // when + ExerciseDetailDTO.Response response = exerciseLifecycleQueryService.getExerciseDetail( + exercise.getId(), manager.getId()); + + // then + assertThat(response.participants().list()).hasSize(1); + assertThat(response.waiting().list()).hasSize(1); + assertThat(response.waiting().currentWaitingCount()).isEqualTo(1); + } + + @Test + @DisplayName("게스트_참가자는_participantType이_GUEST이고_inviterName이_반환된다") + void 게스트_참가자는_participantType이_GUEST이고_inviterName이_반환된다() { + // given + Guest guest = GuestFixture.createGuest(exercise, manager.getId()); + ReflectionTestUtils.setField(guest, "id", 50L); + + given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) + .willReturn(Optional.of(exercise)); + given(memberRepository.findById(manager.getId())) + .willReturn(Optional.of(manager)); + given(memberExerciseRepository.findByExerciseIdWithMemberAndProfile(exercise.getId())) + .willReturn(List.of()); + given(guestRepository.findByExerciseId(exercise.getId())) + .willReturn(List.of(guest)); + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole( + party.getId(), manager.getId(), Role.PARTY_MANAGER)) + .willReturn(true); + given(memberRepository.findMemberNamesByIds(any())) + .willReturn(Map.of(manager.getId(), "모임장")); + + // when + ExerciseDetailDTO.Response response = exerciseLifecycleQueryService.getExerciseDetail( + exercise.getId(), manager.getId()); + + // then + List participants = response.participants().list(); + assertThat(participants).hasSize(1); + assertThat(participants.get(0).participantType()).isEqualTo("GUEST"); + assertThat(participants.get(0).inviterName()).isEqualTo("모임장"); + } + + @Test + @DisplayName("먼저_가입한_참가자가_더_낮은_participantNumber를_받는다") + void 먼저_가입한_참가자가_더_낮은_participantNumber를_받는다() { + // given + Member firstMember = MemberFixture.createMember("첫번째", Gender.MALE, Level.A, 5001L); + ReflectionTestUtils.setField(firstMember, "id", 7L); + + Member secondMember = MemberFixture.createMember("두번째", Gender.FEMALE, Level.B, 5002L); + ReflectionTestUtils.setField(secondMember, "id", 8L); + + MemberExercise first = MemberFixture.createMemberExercise(firstMember, exercise); + ReflectionTestUtils.setField(first, "createdAt", LocalDateTime.now().minusMinutes(10)); + + MemberExercise second = MemberFixture.createMemberExercise(secondMember, exercise); + ReflectionTestUtils.setField(second, "createdAt", LocalDateTime.now()); + + MemberParty firstParty = MemberFixture.createMemberParty(party, firstMember, Role.PARTY_MEMBER); + MemberParty secondParty = MemberFixture.createMemberParty(party, secondMember, Role.PARTY_MEMBER); + + given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) + .willReturn(Optional.of(exercise)); + given(memberRepository.findById(manager.getId())) + .willReturn(Optional.of(manager)); + given(memberExerciseRepository.findByExerciseIdWithMemberAndProfile(exercise.getId())) + .willReturn(List.of(first, second)); + given(guestRepository.findByExerciseId(exercise.getId())) + .willReturn(List.of()); + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole( + party.getId(), manager.getId(), Role.PARTY_MANAGER)) + .willReturn(true); + given(memberPartyRepository.findMemberRolesByPartyAndMembers( + party.getId(), List.of(firstMember.getId(), secondMember.getId()))) + .willReturn(List.of(firstParty, secondParty)); + + // when + ExerciseDetailDTO.Response response = exerciseLifecycleQueryService.getExerciseDetail( + exercise.getId(), manager.getId()); + + // then + List participants = response.participants().list(); + assertThat(participants).hasSize(2); + assertThat(participants.get(0).participantNumber()).isEqualTo(1); + assertThat(participants.get(0).name()).isEqualTo("첫번째"); + assertThat(participants.get(1).participantNumber()).isEqualTo(2); + assertThat(participants.get(1).name()).isEqualTo("두번째"); + } + + @Test + @DisplayName("대기자_성별_카운트가_올바르게_계산된다") + void 대기자_성별_카운트가_올바르게_계산된다() { + // given + ReflectionTestUtils.setField(exercise, "maxCapacity", 1); + + Member maleMember = MemberFixture.createMember("남성", Gender.MALE, Level.A, 6001L); + ReflectionTestUtils.setField(maleMember, "id", 11L); + + Member femaleMember = MemberFixture.createMember("여성", Gender.FEMALE, Level.B, 6002L); + ReflectionTestUtils.setField(femaleMember, "id", 12L); + + MemberExercise first = MemberFixture.createMemberExercise(maleMember, exercise); + ReflectionTestUtils.setField(first, "createdAt", LocalDateTime.now().minusMinutes(10)); + + MemberExercise second = MemberFixture.createMemberExercise(femaleMember, exercise); + ReflectionTestUtils.setField(second, "createdAt", LocalDateTime.now()); + + MemberParty maleParty = MemberFixture.createMemberParty(party, maleMember, Role.PARTY_MEMBER); + MemberParty femaleParty = MemberFixture.createMemberParty(party, femaleMember, Role.PARTY_MEMBER); + + given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) + .willReturn(Optional.of(exercise)); + given(memberRepository.findById(manager.getId())) + .willReturn(Optional.of(manager)); + given(memberExerciseRepository.findByExerciseIdWithMemberAndProfile(exercise.getId())) + .willReturn(List.of(first, second)); + given(guestRepository.findByExerciseId(exercise.getId())) + .willReturn(List.of()); + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole( + party.getId(), manager.getId(), Role.PARTY_MANAGER)) + .willReturn(true); + given(memberPartyRepository.findMemberRolesByPartyAndMembers( + party.getId(), List.of(maleMember.getId(), femaleMember.getId()))) + .willReturn(List.of(maleParty, femaleParty)); + + // when + ExerciseDetailDTO.Response response = exerciseLifecycleQueryService.getExerciseDetail( + exercise.getId(), manager.getId()); + + // then + assertThat(response.participants().manCount()).isEqualTo(1); + assertThat(response.participants().womenCount()).isZero(); + assertThat(response.waiting().manCount()).isZero(); + assertThat(response.waiting().womenCount()).isEqualTo(1); + } + + @Test + @DisplayName("참가자_성별_카운트가_올바르게_계산된다") + void 참가자_성별_카운트가_올바르게_계산된다() { + // given + Member maleMember = MemberFixture.createMember("남성", Gender.MALE, Level.A, 4001L); + ReflectionTestUtils.setField(maleMember, "id", 5L); + + Member femaleMember = MemberFixture.createMember("여성", Gender.FEMALE, Level.B, 4002L); + ReflectionTestUtils.setField(femaleMember, "id", 6L); + + MemberExercise maleExercise = MemberFixture.createMemberExercise(maleMember, exercise); + ReflectionTestUtils.setField(maleExercise, "createdAt", LocalDateTime.now().minusMinutes(5)); + + MemberExercise femaleExercise = MemberFixture.createMemberExercise(femaleMember, exercise); + ReflectionTestUtils.setField(femaleExercise, "createdAt", LocalDateTime.now()); + + MemberParty maleParty = MemberFixture.createMemberParty(party, maleMember, Role.PARTY_MEMBER); + MemberParty femaleParty = MemberFixture.createMemberParty(party, femaleMember, Role.PARTY_MEMBER); + + given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) + .willReturn(Optional.of(exercise)); + given(memberRepository.findById(manager.getId())) + .willReturn(Optional.of(manager)); + given(memberExerciseRepository.findByExerciseIdWithMemberAndProfile(exercise.getId())) + .willReturn(List.of(maleExercise, femaleExercise)); + given(guestRepository.findByExerciseId(exercise.getId())) + .willReturn(List.of()); + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole( + party.getId(), manager.getId(), Role.PARTY_MANAGER)) + .willReturn(true); + given(memberPartyRepository.findMemberRolesByPartyAndMembers( + party.getId(), List.of(maleMember.getId(), femaleMember.getId()))) + .willReturn(List.of(maleParty, femaleParty)); + + // when + ExerciseDetailDTO.Response response = exerciseLifecycleQueryService.getExerciseDetail( + exercise.getId(), manager.getId()); + + // then + assertThat(response.participants().manCount()).isEqualTo(1); + assertThat(response.participants().womenCount()).isEqualTo(1); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지_않는_운동이면_예외를_던진다") + void 존재하지_않는_운동이면_예외를_던진다() { + // given + given(exerciseRepository.findExerciseWithBasicInfo(999L)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> exerciseLifecycleQueryService.getExerciseDetail(999L, manager.getId())) + .isInstanceOf(ExerciseException.class) + .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.EXERCISE_NOT_FOUND); + } + + @Test + @DisplayName("존재하지_않는_멤버면_예외를_던진다") + void 존재하지_않는_멤버면_예외를_던진다() { + // given + given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) + .willReturn(Optional.of(exercise)); + given(memberRepository.findById(999L)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> exerciseLifecycleQueryService.getExerciseDetail(exercise.getId(), 999L)) + .isInstanceOf(MemberException.class) + .hasFieldOrPropertyWithValue("code", MemberErrorCode.MEMBER_NOT_FOUND); + } + } + } + + @Nested + @DisplayName("getExerciseForEdit") + class GetExerciseForEdit { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("운동 수정용 상세 정보의 모든 필드가 올바르게 반환된다") + void 운동_수정용_상세_정보의_모든_필드가_올바르게_반환된다() { + // given + LocalDate targetDate = LocalDate.of(2026, 3, 24); + Exercise exerciseForEdit = ExerciseFixture.createExerciseForEdit(party, targetDate); + ReflectionTestUtils.setField(exerciseForEdit, "id", 101L); + + given(exerciseRepository.findExerciseWithBasicInfo(exerciseForEdit.getId())) + .willReturn(Optional.of(exerciseForEdit)); + + // when + ExerciseEditDetailDTO.Response response = exerciseLifecycleQueryService.getExerciseForEdit( + exerciseForEdit.getId(), manager.getId()); + + // then + assertThat(response.date()).isEqualTo(targetDate); + assertThat(response.buildingName()).isEqualTo("테스트 체육관"); + assertThat(response.roadAddress()).isEqualTo("서울특별시 강남구 테헤란로 1"); + assertThat(response.latitude()).isEqualTo(37.5); + assertThat(response.longitude()).isEqualTo(127.0); + assertThat(response.startTime()).isEqualTo(LocalTime.of(10, 0)); + assertThat(response.endTime()).isEqualTo(LocalTime.of(12, 30)); + assertThat(response.maxCapacity()).isEqualTo(18); + assertThat(response.allowMemberGuestsInvitation()).isTrue(); + assertThat(response.allowExternalGuests()).isFalse(); + assertThat(response.notice()).isEqualTo("수정 공지사항"); + } + + @Test + @DisplayName("부모임장이면 수정용 상세 정보를 조회할 수 있다") + void 부모임장이면_수정용_상세_정보를_조회할_수_있다() { + // given + Member subManager = MemberFixture.createMember("부모임장", Gender.FEMALE, Level.B, 2003L); + ReflectionTestUtils.setField(subManager, "id", 21L); + + Exercise exerciseForEdit = ExerciseFixture.createExerciseForEdit(party, LocalDate.of(2026, 3, 24)); + ReflectionTestUtils.setField(exerciseForEdit, "id", 101L); + + given(exerciseRepository.findExerciseWithBasicInfo(exerciseForEdit.getId())) + .willReturn(Optional.of(exerciseForEdit)); + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole( + party.getId(), subManager.getId(), Role.PARTY_MANAGER)) + .willReturn(false); + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole( + party.getId(), subManager.getId(), Role.PARTY_SUBMANAGER)) + .willReturn(true); + + // when + ExerciseEditDetailDTO.Response response = exerciseLifecycleQueryService.getExerciseForEdit( + exerciseForEdit.getId(), subManager.getId()); + + // then + assertThat(response.date()).isEqualTo(LocalDate.of(2026, 3, 24)); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지_않는_운동이면_예외를_던진다") + void 존재하지_않는_운동이면_예외를_던진다() { + // given + given(exerciseRepository.findExerciseWithBasicInfo(999L)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> exerciseLifecycleQueryService.getExerciseForEdit(999L, manager.getId())) + .isInstanceOf(ExerciseException.class) + .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.EXERCISE_NOT_FOUND); + } + + @Test + @DisplayName("모임장이나 부모임장이 아니면 예외를 던진다") + void 모임장이나_부모임장이_아니면_예외를_던진다() { + // given + Member normalMember = MemberFixture.createMember("일반멤버", Gender.MALE, Level.C, 2004L); + ReflectionTestUtils.setField(normalMember, "id", 22L); + + Exercise exerciseForEdit = ExerciseFixture.createExerciseForEdit(party, LocalDate.of(2026, 3, 24)); + ReflectionTestUtils.setField(exerciseForEdit, "id", 101L); + + given(exerciseRepository.findExerciseWithBasicInfo(exerciseForEdit.getId())) + .willReturn(Optional.of(exerciseForEdit)); + + // when & then + assertThatThrownBy(() -> exerciseLifecycleQueryService.getExerciseForEdit( + exerciseForEdit.getId(), normalMember.getId())) + .isInstanceOf(ExerciseException.class) + .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.INSUFFICIENT_PERMISSION); + } + } + } +} diff --git a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseMapQueryServiceTest.java b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseMapQueryServiceTest.java new file mode 100644 index 000000000..e45c6e222 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseMapQueryServiceTest.java @@ -0,0 +1,412 @@ +package umc.cockple.demo.domain.exercise.service; + +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.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import umc.cockple.demo.domain.bookmark.repository.ExerciseBookmarkRepository; +import umc.cockple.demo.domain.exercise.converter.ExerciseConverter; +import umc.cockple.demo.domain.exercise.domain.Exercise; +import umc.cockple.demo.domain.exercise.dto.ExerciseBuildingDetailDTO; +import umc.cockple.demo.domain.exercise.dto.ExerciseMapBuildingsDTO; +import umc.cockple.demo.domain.exercise.exception.ExerciseErrorCode; +import umc.cockple.demo.domain.exercise.exception.ExerciseException; +import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; +import umc.cockple.demo.domain.exercise.service.query.ExerciseMapQueryService; +import umc.cockple.demo.domain.bookmark.service.query.lookup.ExerciseBookmarkLookupService; +import umc.cockple.demo.domain.exercise.service.support.reader.ExerciseReader; +import umc.cockple.demo.domain.file.service.FileService; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.domain.MemberAddr; +import umc.cockple.demo.domain.member.exception.MemberErrorCode; +import umc.cockple.demo.domain.member.exception.MemberException; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.domain.member.service.support.MemberLookupService; +import umc.cockple.demo.domain.party.domain.Party; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.support.fixture.ExerciseFixture; +import umc.cockple.demo.support.fixture.MemberFixture; +import umc.cockple.demo.support.fixture.PartyFixture; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.YearMonth; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.groups.Tuple.tuple; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ExerciseMapQueryService") +class ExerciseMapQueryServiceTest { + + private ExerciseMapQueryService exerciseMapQueryService; + + @Mock private ExerciseRepository exerciseRepository; + @Mock private MemberRepository memberRepository; + @Mock private ExerciseBookmarkRepository exerciseBookmarkRepository; + @Mock private FileService fileService; + + private Party party; + + @BeforeEach + void setUp() { + ExerciseConverter exerciseConverter = new ExerciseConverter(fileService); + exerciseMapQueryService = new ExerciseMapQueryService( + new ExerciseReader(exerciseRepository), + new ExerciseBookmarkLookupService(exerciseBookmarkRepository), + new MemberLookupService(memberRepository), + exerciseConverter + ); + + Member manager = MemberFixture.createMember("모임장", Gender.MALE, Level.A, 1001L); + ReflectionTestUtils.setField(manager, "id", 1L); + party = PartyFixture.createParty("테스트 모임", manager.getId(), + PartyFixture.createPartyAddr("서울특별시", "강남구")); + ReflectionTestUtils.setField(party, "id", 10L); + } + + @Nested + @DisplayName("getBuildingExerciseDetails") + class GetBuildingExerciseDetails { + + private Member buildingMember; + private LocalDate targetDate; + private String buildingName; + private String streetAddr; + + @BeforeEach + void setUp() { + buildingMember = MemberFixture.createMember("건물상세멤버", Gender.FEMALE, Level.B, 8001L, + LocalDate.of(2000, 1, 1)); + ReflectionTestUtils.setField(buildingMember, "id", 8L); + + targetDate = LocalDate.of(2026, 5, 10); + buildingName = "콕플 타워"; + streetAddr = "서울특별시 강남구 테헤란로 10"; + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("해당 건물 운동이 없으면 메타데이터가 포함된 빈 응답을 반환한다") + void 해당_건물_운동이_없으면_메타데이터가_포함된_빈_응답을_반환한다() { + // given + given(exerciseRepository.findExercisesByBuildingAndDate(buildingName, streetAddr, targetDate)) + .willReturn(List.of()); + + // when + ExerciseBuildingDetailDTO.Response response = exerciseMapQueryService.getBuildingExerciseDetails( + buildingName, streetAddr, targetDate, buildingMember.getId()); + + // then + assertThat(response.date()).isEqualTo(targetDate); + assertThat(response.dayOfWeek()).isEqualTo("SUNDAY"); + assertThat(response.buildingName()).isEqualTo(buildingName); + assertThat(response.exercises()).isEmpty(); + verify(exerciseRepository).findExercisesByBuildingAndDate(buildingName, streetAddr, targetDate); + verify(exerciseBookmarkRepository, never()).findAllExerciseIdsByMemberIdAndExerciseIds(any(), any()); + } + + @Test + @DisplayName("운동 목록을 순서와 북마크 상태를 유지해 DTO로 반환한다") + void 운동_목록을_순서와_북마크_상태를_유지해_DTO로_반환한다() { + // given + Exercise morningExercise = createBuildingExercise(801L, LocalTime.of(9, 0), LocalTime.of(11, 0)); + Exercise eveningExercise = createBuildingExercise(802L, LocalTime.of(19, 0), LocalTime.of(21, 0)); + given(exerciseRepository.findExercisesByBuildingAndDate(buildingName, streetAddr, targetDate)) + .willReturn(List.of(morningExercise, eveningExercise)); + given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( + buildingMember.getId(), List.of(morningExercise.getId(), eveningExercise.getId()))) + .willReturn(List.of(eveningExercise.getId())); + + // when + ExerciseBuildingDetailDTO.Response response = exerciseMapQueryService.getBuildingExerciseDetails( + buildingName, streetAddr, targetDate, buildingMember.getId()); + + // then + assertThat(response.date()).isEqualTo(targetDate); + assertThat(response.dayOfWeek()).isEqualTo("SUNDAY"); + assertThat(response.buildingName()).isEqualTo(buildingName); + assertThat(response.exercises()) + .extracting( + ExerciseBuildingDetailDTO.ExerciseItem::exerciseId, + ExerciseBuildingDetailDTO.ExerciseItem::partyId, + ExerciseBuildingDetailDTO.ExerciseItem::partyName, + ExerciseBuildingDetailDTO.ExerciseItem::profileImageUrl, + ExerciseBuildingDetailDTO.ExerciseItem::isBookmarked, + ExerciseBuildingDetailDTO.ExerciseItem::startTime, + ExerciseBuildingDetailDTO.ExerciseItem::endTime + ) + .containsExactly( + tuple(801L, 10L, "테스트 모임", null, false, LocalTime.of(9, 0), LocalTime.of(11, 0)), + tuple(802L, 10L, "테스트 모임", null, true, LocalTime.of(19, 0), LocalTime.of(21, 0)) + ); + verify(exerciseRepository).findExercisesByBuildingAndDate(buildingName, streetAddr, targetDate); + verify(exerciseBookmarkRepository).findAllExerciseIdsByMemberIdAndExerciseIds( + buildingMember.getId(), List.of(morningExercise.getId(), eveningExercise.getId())); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + } + + private Exercise createBuildingExercise(long id, LocalTime startTime, LocalTime endTime) { + Exercise buildingExercise = ExerciseFixture.createExerciseWithAddr(party, targetDate, 12); + ReflectionTestUtils.setField(buildingExercise, "id", id); + ReflectionTestUtils.setField(buildingExercise, "startTime", startTime); + ReflectionTestUtils.setField(buildingExercise, "endTime", endTime); + ReflectionTestUtils.setField(buildingExercise, "exerciseAddr", + ExerciseFixture.createExerciseAddr(buildingName, streetAddr)); + return buildingExercise; + } + } + + @Nested + @DisplayName("getExerciseMapCalendarSummary") + class GetExerciseMapCalendarSummary { + + private Member mapMember; + private Member memberWithoutMainAddr; + private MemberAddr mainAddr; + private Double radiusKm; + + @BeforeEach + void setUp() { + mapMember = MemberFixture.createMember("지도멤버", Gender.MALE, Level.B, 9001L, + LocalDate.of(2000, 1, 1)); + ReflectionTestUtils.setField(mapMember, "id", 9L); + + mainAddr = MemberAddr.builder() + .member(mapMember) + .addr1("서울특별시") + .addr2("강남구") + .addr3("역삼동") + .streetAddr("서울특별시 강남구 테헤란로 1") + .buildingName("대표주소") + .latitude(37.501) + .longitude(127.039) + .isMain(true) + .build(); + ReflectionTestUtils.setField(mapMember, "addresses", List.of(mainAddr)); + + memberWithoutMainAddr = MemberFixture.createMember("대표주소없음", Gender.FEMALE, Level.C, 9002L, + LocalDate.of(2001, 1, 1)); + ReflectionTestUtils.setField(memberWithoutMainAddr, "id", 10L); + MemberAddr subAddr = MemberAddr.builder() + .member(memberWithoutMainAddr) + .addr1("서울특별시") + .addr2("송파구") + .addr3("잠실동") + .streetAddr("서울특별시 송파구 올림픽로 1") + .buildingName("서브주소") + .latitude(37.514) + .longitude(127.102) + .isMain(false) + .build(); + ReflectionTestUtils.setField(memberWithoutMainAddr, "addresses", List.of(subAddr)); + + radiusKm = 3.9; + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("date가 null이면 현재 월 범위와 대표주소 좌표로 조회한다") + void date가_null이면_현재_월_범위와_대표주소_좌표로_조회한다() { + // given + YearMonth currentMonth = YearMonth.now(); + LocalDate monthStart = currentMonth.atDay(1); + LocalDate monthEnd = currentMonth.atEndOfMonth(); + + given(memberRepository.findMemberWithAddresses(mapMember.getId())) + .willReturn(Optional.of(mapMember)); + given(exerciseRepository.findExercisesByMonthAndRadius( + eq(monthStart), + eq(monthEnd), + eq(37.501), + eq(127.039), + eq(radiusKm))) + .willReturn(List.of()); + + // when + ExerciseMapBuildingsDTO.Response response = exerciseMapQueryService.getExerciseMapCalendarSummary( + createMapQuery(null, null, null, radiusKm), mapMember.getId()); + + // then + assertThat(response.year()).isEqualTo(currentMonth.getYear()); + assertThat(response.month()).isEqualTo(currentMonth.getMonthValue()); + assertThat(response.centerLatitude()).isEqualTo(mainAddr.getLatitude()); + assertThat(response.centerLongitude()).isEqualTo(mainAddr.getLongitude()); + assertThat(response.radiusKm()).isEqualTo(radiusKm); + assertThat(response.buildings()).isEmpty(); + } + + @Test + @DisplayName("명시 좌표가 있으면 대표주소 대신 해당 좌표와 소수 반경으로 조회한다") + void 명시_좌표가_있으면_대표주소_대신_해당_좌표와_소수_반경으로_조회한다() { + // given + LocalDate targetDate = LocalDate.of(2026, 4, 15); + LocalDate monthStart = LocalDate.of(2026, 4, 1); + LocalDate monthEnd = LocalDate.of(2026, 4, 30); + + given(memberRepository.findMemberWithAddresses(mapMember.getId())) + .willReturn(Optional.of(mapMember)); + given(exerciseRepository.findExercisesByMonthAndRadius( + eq(monthStart), + eq(monthEnd), + eq(37.55), + eq(127.11), + eq(radiusKm))) + .willReturn(List.of()); + + // when + ExerciseMapBuildingsDTO.Response response = exerciseMapQueryService.getExerciseMapCalendarSummary( + createMapQuery(targetDate, 37.55, 127.11, radiusKm), mapMember.getId()); + + // then + assertThat(response.year()).isEqualTo(2026); + assertThat(response.month()).isEqualTo(4); + assertThat(response.centerLatitude()).isEqualTo(37.55); + assertThat(response.centerLongitude()).isEqualTo(127.11); + assertThat(response.radiusKm()).isEqualTo(radiusKm); + assertThat(response.buildings()).isEmpty(); + } + + @Test + @DisplayName("운동을 날짜별과 건물별로 그룹화해 응답을 만든다") + void 운동을_날짜별과_건물별로_그룹화해_응답을_만든다() { + // given + LocalDate targetDate = LocalDate.of(2026, 4, 15); + Exercise dayOneMorning = createMapExercise(901L, LocalDate.of(2026, 4, 3), + "A빌딩", "서울특별시 강남구 테헤란로 10", 37.501, 127.041, LocalTime.of(9, 0)); + Exercise dayOneEveningSameBuilding = createMapExercise(902L, LocalDate.of(2026, 4, 3), + "A빌딩", "서울특별시 강남구 테헤란로 10", 37.501, 127.041, LocalTime.of(19, 0)); + Exercise dayOneOtherBuilding = createMapExercise(903L, LocalDate.of(2026, 4, 3), + "B빌딩", "서울특별시 강남구 테헤란로 20", 37.502, 127.042, LocalTime.of(13, 0)); + Exercise dayTwoBuilding = createMapExercise(904L, LocalDate.of(2026, 4, 4), + "A빌딩", "서울특별시 강남구 테헤란로 10", 37.501, 127.041, LocalTime.of(10, 0)); + + given(memberRepository.findMemberWithAddresses(mapMember.getId())) + .willReturn(Optional.of(mapMember)); + given(exerciseRepository.findExercisesByMonthAndRadius( + any(), any(), any(), any(), any())) + .willReturn(List.of(dayOneMorning, dayOneEveningSameBuilding, dayOneOtherBuilding, dayTwoBuilding)); + + // when + ExerciseMapBuildingsDTO.Response response = exerciseMapQueryService.getExerciseMapCalendarSummary( + createMapQuery(targetDate, null, null, radiusKm), mapMember.getId()); + + // then + assertThat(response.year()).isEqualTo(2026); + assertThat(response.month()).isEqualTo(4); + assertThat(response.centerLatitude()).isEqualTo(mainAddr.getLatitude()); + assertThat(response.centerLongitude()).isEqualTo(mainAddr.getLongitude()); + assertThat(response.radiusKm()).isEqualTo(radiusKm); + assertThat(response.buildings().keySet()) + .containsExactly(LocalDate.of(2026, 4, 3), LocalDate.of(2026, 4, 4)); + assertThat(response.buildings().get(LocalDate.of(2026, 4, 3))) + .extracting( + ExerciseMapBuildingsDTO.BuildingInfo::buildingName, + ExerciseMapBuildingsDTO.BuildingInfo::streetAddr, + ExerciseMapBuildingsDTO.BuildingInfo::latitude, + ExerciseMapBuildingsDTO.BuildingInfo::longitude + ) + .containsExactlyInAnyOrder( + tuple("A빌딩", "서울특별시 강남구 테헤란로 10", 37.501, 127.041), + tuple("B빌딩", "서울특별시 강남구 테헤란로 20", 37.502, 127.042) + ); + assertThat(response.buildings().get(LocalDate.of(2026, 4, 4))) + .extracting( + ExerciseMapBuildingsDTO.BuildingInfo::buildingName, + ExerciseMapBuildingsDTO.BuildingInfo::streetAddr + ) + .containsExactly(tuple("A빌딩", "서울특별시 강남구 테헤란로 10")); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 멤버면 예외를 던진다") + void 존재하지_않는_멤버면_예외를_던진다() { + // given + given(memberRepository.findMemberWithAddresses(999L)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> exerciseMapQueryService.getExerciseMapCalendarSummary( + createMapQuery(LocalDate.of(2026, 4, 1), null, null, radiusKm), 999L)) + .isInstanceOf(MemberException.class) + .hasFieldOrPropertyWithValue("code", MemberErrorCode.MEMBER_NOT_FOUND); + } + + @Test + @DisplayName("대표주소가 없으면 예외를 던진다") + void 대표주소가_없으면_예외를_던진다() { + // given + given(memberRepository.findMemberWithAddresses(memberWithoutMainAddr.getId())) + .willReturn(Optional.of(memberWithoutMainAddr)); + + // when & then + assertThatThrownBy(() -> exerciseMapQueryService.getExerciseMapCalendarSummary( + createMapQuery(LocalDate.of(2026, 4, 1), null, null, radiusKm), memberWithoutMainAddr.getId())) + .isInstanceOf(MemberException.class) + .hasFieldOrPropertyWithValue("code", MemberErrorCode.MAIN_ADDRESS_NULL); + } + + @Test + @DisplayName("대표주소가 없으면 명시 좌표가 있어도 예외를 던진다") + void 대표주소가_없으면_명시_좌표가_있어도_예외를_던진다() { + // given + given(memberRepository.findMemberWithAddresses(memberWithoutMainAddr.getId())) + .willReturn(Optional.of(memberWithoutMainAddr)); + + // when & then + assertThatThrownBy(() -> exerciseMapQueryService.getExerciseMapCalendarSummary( + createMapQuery(LocalDate.of(2026, 4, 1), 37.5, 127.0, radiusKm), memberWithoutMainAddr.getId())) + .isInstanceOf(MemberException.class) + .hasFieldOrPropertyWithValue("code", MemberErrorCode.MAIN_ADDRESS_NULL); + } + + } + + private ExerciseMapBuildingsDTO.Query createMapQuery( + LocalDate date, Double latitude, Double longitude, Double radiusKm) { + return ExerciseMapBuildingsDTO.Query.of(date, latitude, longitude, radiusKm); + } + + private Exercise createMapExercise(long id, LocalDate date, String buildingName, + String streetAddr, double latitude, double longitude, + LocalTime startTime) { + Exercise mapExercise = ExerciseFixture.createExerciseWithAddr(party, date, 12); + ReflectionTestUtils.setField(mapExercise, "id", id); + ReflectionTestUtils.setField(mapExercise, "startTime", startTime); + ReflectionTestUtils.setField(mapExercise, "exerciseAddr", + ExerciseFixture.createExerciseAddr(buildingName, streetAddr, latitude, longitude)); + return mapExercise; + } + } + + +} diff --git a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseMyQueryServiceTest.java b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseMyQueryServiceTest.java new file mode 100644 index 000000000..209f74c3b --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseMyQueryServiceTest.java @@ -0,0 +1,738 @@ +package umc.cockple.demo.domain.exercise.service; + +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.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.data.domain.Sort; +import org.springframework.test.util.ReflectionTestUtils; +import umc.cockple.demo.domain.bookmark.repository.ExerciseBookmarkRepository; +import umc.cockple.demo.domain.exercise.converter.ExerciseConverter; +import umc.cockple.demo.domain.exercise.domain.Exercise; +import umc.cockple.demo.domain.exercise.domain.Guest; +import umc.cockple.demo.domain.exercise.dto.ExerciseBuildingDetailDTO; +import umc.cockple.demo.domain.exercise.dto.ExerciseDetailDTO; +import umc.cockple.demo.domain.exercise.dto.ExerciseEditDetailDTO; +import umc.cockple.demo.domain.exercise.dto.ExerciseMapBuildingsDTO; +import umc.cockple.demo.domain.exercise.dto.ExerciseMyGuestListDTO; +import umc.cockple.demo.domain.exercise.dto.MyExerciseCalendarDTO; +import umc.cockple.demo.domain.exercise.dto.MyExerciseListDTO; +import umc.cockple.demo.domain.exercise.dto.MyPartyExerciseCalendarDTO; +import umc.cockple.demo.domain.exercise.dto.MyPartyExerciseDTO; +import umc.cockple.demo.domain.exercise.dto.PartyExerciseCalendarDTO; +import umc.cockple.demo.domain.exercise.enums.MyExerciseFilterType; +import umc.cockple.demo.domain.exercise.enums.MyExerciseOrderType; +import umc.cockple.demo.domain.exercise.enums.MyPartyExerciseOrderType; +import umc.cockple.demo.domain.exercise.exception.ExerciseErrorCode; +import umc.cockple.demo.domain.exercise.exception.ExerciseException; +import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; +import umc.cockple.demo.domain.exercise.repository.GuestRepository; +import umc.cockple.demo.domain.exercise.service.query.lookup.ExerciseParticipantCountLookupService; +import umc.cockple.demo.domain.bookmark.service.query.lookup.ExerciseBookmarkLookupService; +import umc.cockple.demo.domain.exercise.service.support.reader.ExerciseParticipantReader; +import umc.cockple.demo.domain.exercise.service.support.reader.ExerciseReader; +import umc.cockple.demo.domain.exercise.service.support.reader.GuestReader; +import umc.cockple.demo.domain.exercise.service.query.ExerciseMyQueryService; +import umc.cockple.demo.domain.party.service.support.PartyLookupService; +import umc.cockple.demo.domain.file.service.FileService; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.domain.MemberExercise; +import umc.cockple.demo.domain.member.domain.MemberParty; +import umc.cockple.demo.domain.member.repository.MemberExerciseRepository; +import umc.cockple.demo.domain.member.repository.MemberPartyRepository; +import umc.cockple.demo.domain.party.domain.Party; +import umc.cockple.demo.domain.party.enums.PartyStatus; +import umc.cockple.demo.domain.party.exception.PartyErrorCode; +import umc.cockple.demo.domain.party.exception.PartyException; +import umc.cockple.demo.domain.party.repository.PartyRepository; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.global.enums.Role; +import umc.cockple.demo.support.ExerciseCalendarTestHelper; +import umc.cockple.demo.support.fixture.ExerciseFixture; +import umc.cockple.demo.support.fixture.GuestFixture; +import umc.cockple.demo.support.fixture.MemberFixture; +import umc.cockple.demo.support.fixture.PartyFixture; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.YearMonth; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.groups.Tuple.tuple; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ExerciseMyQueryService") +class ExerciseMyQueryServiceTest { + + private ExerciseMyQueryService exerciseMyQueryService; + + @Mock private ExerciseRepository exerciseRepository; + @Mock private MemberPartyRepository memberPartyRepository; + @Mock private MemberExerciseRepository memberExerciseRepository; + @Mock private ExerciseBookmarkRepository exerciseBookmarkRepository; + @Mock private FileService fileService; + + private Party party; + + @BeforeEach + void setUp() { + ExerciseConverter exerciseConverter = new ExerciseConverter(fileService); + exerciseMyQueryService = new ExerciseMyQueryService( + new ExerciseReader(exerciseRepository), + new ExerciseParticipantReader(memberExerciseRepository, memberPartyRepository), + new ExerciseParticipantCountLookupService(exerciseRepository), + new ExerciseBookmarkLookupService(exerciseBookmarkRepository), + exerciseConverter + ); + + Member manager = MemberFixture.createMember("모임장", Gender.MALE, Level.A, 1001L); + ReflectionTestUtils.setField(manager, "id", 1L); + + party = PartyFixture.createParty("테스트 모임", manager.getId(), + PartyFixture.createPartyAddr("서울특별시", "강남구")); + ReflectionTestUtils.setField(party, "id", 10L); + } + + @Nested + @DisplayName("getMyExerciseCalendar") + class GetMyExerciseCalendar { + + private Member calendarMember; + private LocalDate startDate; + private LocalDate endDate; + private Exercise myExercise; + + @BeforeEach + void setUp() { + calendarMember = MemberFixture.createMember("캘린더멤버", Gender.FEMALE, Level.B, 4001L); + ReflectionTestUtils.setField(calendarMember, "id", 4L); + + startDate = LocalDate.of(2026, 3, 23); + endDate = LocalDate.of(2026, 3, 29); + + myExercise = ExerciseFixture.createExerciseWithAddr(party, LocalDate.of(2026, 3, 25)); + ReflectionTestUtils.setField(myExercise, "id", 200L); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("내 운동 캘린더를 주차별_일자별로 반환한다") + void 내_운동_캘린더를_주차별_일자별로_반환한다() { + // given + given(exerciseRepository.findByMemberIdAndDateRange(calendarMember.getId(), startDate, endDate)) + .willReturn(List.of(myExercise)); + + // when + MyExerciseCalendarDTO.Response response = exerciseMyQueryService.getMyExerciseCalendar( + calendarMember.getId(), startDate, endDate); + + // then + assertThat(response.startDate()).isEqualTo(startDate); + assertThat(response.endDate()).isEqualTo(endDate); + assertThat(response.weeks()).hasSize(1); + assertThat(response.weeks().get(0).weekStartDate()).isEqualTo(startDate); + assertThat(response.weeks().get(0).weekEndDate()).isEqualTo(endDate); + assertThat(response.weeks().get(0).days()).hasSize(7); + assertThat(response.weeks().get(0).days().get(2).date()) + .isEqualTo(LocalDate.of(2026, 3, 25)); + assertThat(response.weeks().get(0).days().get(2).exercises()) + .extracting( + MyExerciseCalendarDTO.ExerciseCalendarItem::exerciseId, + MyExerciseCalendarDTO.ExerciseCalendarItem::partyId, + MyExerciseCalendarDTO.ExerciseCalendarItem::partyName, + MyExerciseCalendarDTO.ExerciseCalendarItem::buildingName, + MyExerciseCalendarDTO.ExerciseCalendarItem::startTime, + MyExerciseCalendarDTO.ExerciseCalendarItem::endTime, + MyExerciseCalendarDTO.ExerciseCalendarItem::profileImageUrl) + .containsExactly(tuple(200L, 10L, "테스트 모임", "테스트 체육관", LocalTime.of(10, 0), null, null)); + } + + @Test + @DisplayName("기간 내 참여 운동이 없으면 빈 캘린더를 반환한다") + void 기간_내_참여_운동이_없으면_빈_캘린더를_반환한다() { + // given + given(exerciseRepository.findByMemberIdAndDateRange(calendarMember.getId(), startDate, endDate)) + .willReturn(List.of()); + + // when + MyExerciseCalendarDTO.Response response = exerciseMyQueryService.getMyExerciseCalendar( + calendarMember.getId(), startDate, endDate); + + // then + assertThat(response.startDate()).isEqualTo(startDate); + assertThat(response.endDate()).isEqualTo(endDate); + assertThat(response.weeks()).isEmpty(); + } + + @Test + @DisplayName("시작일과_종료일이_없으면_기본_기간이_적용된다") + void 시작일과_종료일이_없으면_기본_기간이_적용된다() { + // given + LocalDate expectedStart = ExerciseCalendarTestHelper.expectedDefaultStartDate(); + LocalDate expectedEnd = ExerciseCalendarTestHelper.expectedDefaultEndDate(); + given(exerciseRepository.findByMemberIdAndDateRange(calendarMember.getId(), expectedStart, expectedEnd)) + .willReturn(List.of()); + + // when + MyExerciseCalendarDTO.Response response = exerciseMyQueryService.getMyExerciseCalendar( + calendarMember.getId(), null, null); + + // then + assertThat(response.startDate()).isEqualTo(expectedStart); + assertThat(response.endDate()).isEqualTo(expectedEnd); + assertThat(response.weeks()).isEmpty(); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("시작일과 종료일이 함께 오지 않으면 예외를 던진다") + void 시작일과_종료일이_함께_오지_않으면_예외를_던진다() { + // given + + // when & then + assertThatThrownBy(() -> exerciseMyQueryService.getMyExerciseCalendar( + calendarMember.getId(), startDate, null)) + .isInstanceOf(ExerciseException.class) + .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.INCOMPLETE_DATE_RANGE); + } + + @Test + @DisplayName("시작일이 종료일과 같거나 늦으면 예외를 던진다") + void 시작일이_종료일과_같거나_늦으면_예외를_던진다() { + // given + + // when & then + assertThatThrownBy(() -> exerciseMyQueryService.getMyExerciseCalendar( + calendarMember.getId(), endDate, startDate)) + .isInstanceOf(ExerciseException.class) + .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.INVALID_DATE_RANGE); + } + } + } + + @Nested + @DisplayName("getMyPartyExercise") + class GetMyPartyExercise { + + private Member partyMember; + private Exercise firstUpcomingExercise; + private Exercise secondUpcomingExercise; + + @BeforeEach + void setUp() { + partyMember = MemberFixture.createMember("내모임멤버", Gender.MALE, Level.B, 5001L); + ReflectionTestUtils.setField(partyMember, "id", 5L); + + firstUpcomingExercise = ExerciseFixture.createExerciseWithAddr(party, LocalDate.of(2026, 4, 1)); + ReflectionTestUtils.setField(firstUpcomingExercise, "id", 301L); + + secondUpcomingExercise = ExerciseFixture.createExerciseWithAddr(party, LocalDate.of(2026, 4, 2)); + ReflectionTestUtils.setField(secondUpcomingExercise, "id", 302L); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("내 모임의 예정된 운동 목록을 반환한다") + void 내_모임의_예정된_운동_목록을_반환한다() { + // given + given(memberPartyRepository.findPartyIdsByMemberId(partyMember.getId())) + .willReturn(List.of(party.getId())); + given(exerciseRepository.findRecentExercisesByPartyIds(eq(List.of(party.getId())), argThat( + (org.springframework.data.domain.Pageable pageable) -> pageable.getPageNumber() == 0 && pageable.getPageSize() == 6))) + .willReturn(List.of(firstUpcomingExercise, secondUpcomingExercise)); + + // when + MyPartyExerciseDTO.Response response = exerciseMyQueryService.getMyPartyExercise(partyMember.getId()); + + // then + assertThat(response.totalExercises()).isEqualTo(2); + assertThat(response.exercises()) + .extracting( + MyPartyExerciseDTO.Exercises::exerciseId, + MyPartyExerciseDTO.Exercises::partyId, + MyPartyExerciseDTO.Exercises::partyName, + MyPartyExerciseDTO.Exercises::buildingName, + MyPartyExerciseDTO.Exercises::date, + MyPartyExerciseDTO.Exercises::dayOfWeek, + MyPartyExerciseDTO.Exercises::startTime, + MyPartyExerciseDTO.Exercises::profileImageUrl) + .containsExactly( + tuple(301L, 10L, "테스트 모임", "테스트 체육관", LocalDate.of(2026, 4, 1), "WEDNESDAY", LocalTime.of(10, 0), null), + tuple(302L, 10L, "테스트 모임", "테스트 체육관", LocalDate.of(2026, 4, 2), "THURSDAY", LocalTime.of(10, 0), null) + ); + } + + @Test + @DisplayName("속한 모임이 없으면 빈 응답을 반환한다") + void 속한_모임이_없으면_빈_응답을_반환한다() { + // given + given(memberPartyRepository.findPartyIdsByMemberId(partyMember.getId())) + .willReturn(List.of()); + + // when + MyPartyExerciseDTO.Response response = exerciseMyQueryService.getMyPartyExercise(partyMember.getId()); + + // then + assertThat(response.totalExercises()).isZero(); + assertThat(response.exercises()).isEmpty(); + verify(exerciseRepository, never()).findRecentExercisesByPartyIds(any(), any()); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + } + } + + @Nested + @DisplayName("getMyPartyExerciseCalendar") + class GetMyPartyExerciseCalendar { + + private Member calendarMember; + private Exercise calendarExercise; + private LocalDate startDate; + private LocalDate endDate; + + @BeforeEach + void setUp() { + calendarMember = MemberFixture.createMember("내모임캘린더멤버", Gender.FEMALE, Level.B, 6001L); + ReflectionTestUtils.setField(calendarMember, "id", 6L); + + startDate = LocalDate.of(2026, 3, 23); + endDate = LocalDate.of(2026, 3, 29); + + calendarExercise = ExerciseFixture.createExerciseWithAddr(party, LocalDate.of(2026, 3, 25)); + ReflectionTestUtils.setField(calendarExercise, "id", 400L); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("내 모임 운동 캘린더를 주차별_일자별로 반환한다") + void 내_모임_운동_캘린더를_주차별_일자별로_반환한다() { + // given + given(memberPartyRepository.findPartyIdsByMemberId(calendarMember.getId())) + .willReturn(List.of(party.getId())); + given(exerciseRepository.findByPartyIdsAndDateRange(List.of(party.getId()), startDate, endDate)) + .willReturn(List.of(calendarExercise)); + given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( + calendarMember.getId(), List.of(calendarExercise.getId()))) + .willReturn(List.of()); + given(exerciseRepository.findExerciseParticipantCountsByExerciseIds( + List.of(calendarExercise.getId()), startDate, endDate)) + .willReturn(Collections.singletonList(new Object[]{calendarExercise.getId(), 3})); + + // when + MyPartyExerciseCalendarDTO.Response response = exerciseMyQueryService.getMyPartyExerciseCalendar( + calendarMember.getId(), MyPartyExerciseOrderType.LATEST, startDate, endDate); + + // then + assertThat(response.startDate()).isEqualTo(startDate); + assertThat(response.endDate()).isEqualTo(endDate); + assertThat(response.weeks()).hasSize(1); + assertThat(response.weeks().get(0).weekStartDate()).isEqualTo(startDate); + assertThat(response.weeks().get(0).weekEndDate()).isEqualTo(endDate); + assertThat(response.weeks().get(0).days()).hasSize(7); + assertThat(response.weeks().get(0).days().get(2).date()) + .isEqualTo(LocalDate.of(2026, 3, 25)); + assertThat(response.weeks().get(0).days().get(2).exercises()) + .extracting( + MyPartyExerciseCalendarDTO.ExerciseCalendarItem::exerciseId, + MyPartyExerciseCalendarDTO.ExerciseCalendarItem::partyId, + MyPartyExerciseCalendarDTO.ExerciseCalendarItem::partyName, + MyPartyExerciseCalendarDTO.ExerciseCalendarItem::buildingName, + MyPartyExerciseCalendarDTO.ExerciseCalendarItem::isBookmarked, + MyPartyExerciseCalendarDTO.ExerciseCalendarItem::nowCapacity) + .containsExactly(tuple(400L, 10L, "테스트 모임", "테스트 체육관", false, 3)); + } + + @Test + @DisplayName("북마크한 운동은 isBookmarked가 true로 반환된다") + void 북마크한_운동은_isBookmarked가_true로_반환된다() { + // given + given(memberPartyRepository.findPartyIdsByMemberId(calendarMember.getId())) + .willReturn(List.of(party.getId())); + given(exerciseRepository.findByPartyIdsAndDateRange(List.of(party.getId()), startDate, endDate)) + .willReturn(List.of(calendarExercise)); + given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( + calendarMember.getId(), List.of(calendarExercise.getId()))) + .willReturn(List.of(calendarExercise.getId())); + given(exerciseRepository.findExerciseParticipantCountsByExerciseIds( + List.of(calendarExercise.getId()), startDate, endDate)) + .willReturn(List.of()); + + // when + MyPartyExerciseCalendarDTO.Response response = exerciseMyQueryService.getMyPartyExerciseCalendar( + calendarMember.getId(), MyPartyExerciseOrderType.LATEST, startDate, endDate); + + // then + assertThat(response.weeks().get(0).days().get(2).exercises().get(0).isBookmarked()).isTrue(); + } + + @Test + @DisplayName("속한 모임이 없으면 빈 캘린더를 반환한다") + void 속한_모임이_없으면_빈_캘린더를_반환한다() { + // given + given(memberPartyRepository.findPartyIdsByMemberId(calendarMember.getId())) + .willReturn(List.of()); + + // when + MyPartyExerciseCalendarDTO.Response response = exerciseMyQueryService.getMyPartyExerciseCalendar( + calendarMember.getId(), MyPartyExerciseOrderType.LATEST, startDate, endDate); + + // then + assertThat(response.startDate()).isEqualTo(startDate); + assertThat(response.endDate()).isEqualTo(endDate); + assertThat(response.weeks()).isEmpty(); + verify(exerciseRepository, never()).findByPartyIdsAndDateRange(any(), any(), any()); + } + + @Test + @DisplayName("기간 내 내 모임 운동이 없으면 빈 캘린더를 반환한다") + void 기간_내_내_모임_운동이_없으면_빈_캘린더를_반환한다() { + // given + given(memberPartyRepository.findPartyIdsByMemberId(calendarMember.getId())) + .willReturn(List.of(party.getId())); + given(exerciseRepository.findByPartyIdsAndDateRange(List.of(party.getId()), startDate, endDate)) + .willReturn(List.of()); + + // when + MyPartyExerciseCalendarDTO.Response response = exerciseMyQueryService.getMyPartyExerciseCalendar( + calendarMember.getId(), MyPartyExerciseOrderType.LATEST, startDate, endDate); + + // then + assertThat(response.startDate()).isEqualTo(startDate); + assertThat(response.endDate()).isEqualTo(endDate); + assertThat(response.weeks()).isEmpty(); + } + + @Test + @DisplayName("시작일과_종료일이_없으면_기본_기간이_적용된다") + void 시작일과_종료일이_없으면_기본_기간이_적용된다() { + // given + LocalDate expectedStart = ExerciseCalendarTestHelper.expectedDefaultStartDate(); + LocalDate expectedEnd = ExerciseCalendarTestHelper.expectedDefaultEndDate(); + given(memberPartyRepository.findPartyIdsByMemberId(calendarMember.getId())) + .willReturn(List.of(party.getId())); + given(exerciseRepository.findByPartyIdsAndDateRange(List.of(party.getId()), expectedStart, expectedEnd)) + .willReturn(List.of()); + + // when + MyPartyExerciseCalendarDTO.Response response = exerciseMyQueryService.getMyPartyExerciseCalendar( + calendarMember.getId(), MyPartyExerciseOrderType.LATEST, null, null); + + // then + assertThat(response.startDate()).isEqualTo(expectedStart); + assertThat(response.endDate()).isEqualTo(expectedEnd); + assertThat(response.weeks()).isEmpty(); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + } + } + + @Nested + @DisplayName("getMyExercises") + class GetMyExercises { + + private Member myExerciseMember; + private Exercise completedExercise; + private Exercise upcomingExercise; + private Exercise futureLatestExercise; + private Pageable firstPage; + + @BeforeEach + void setUp() { + myExerciseMember = MemberFixture.createMember("내참여운동멤버", Gender.MALE, Level.B, 7001L, + LocalDate.of(2000, 1, 1)); + ReflectionTestUtils.setField(myExerciseMember, "id", 7L); + + party.addLevel(Gender.FEMALE, Level.B); + party.addLevel(Gender.MALE, Level.A); + + completedExercise = createMyExercise(701L, LocalDate.of(2024, 1, 5), + LocalTime.of(9, 0), LocalTime.of(11, 0), 18, false); + upcomingExercise = createMyExercise(702L, LocalDate.of(2099, 1, 3), + LocalTime.of(18, 0), null, 12, true); + futureLatestExercise = createMyExercise(703L, LocalDate.of(2099, 1, 10), + LocalTime.of(7, 30), LocalTime.of(9, 0), 20, true); + firstPage = PageRequest.of(0, 2); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("ALL 최신순은 전체 운동 리포지토리를 날짜 내림차순으로 호출한다") + void ALL_최신순은_전체_운동_리포지토리를_날짜_내림차순으로_호출한다() { + // given + given(exerciseRepository.findMyExercisesWithPaging(eq(myExerciseMember.getId()), argThat( + pageable -> matchesSort(pageable, Sort.Direction.DESC, Sort.Direction.DESC)))) + .willReturn(emptySlice(firstPage)); + + // when + MyExerciseListDTO.Response response = exerciseMyQueryService.getMyExercises( + myExerciseMember.getId(), MyExerciseFilterType.ALL, MyExerciseOrderType.LATEST, firstPage); + + // then + assertThat(response.totalCount()).isZero(); + assertThat(response.hasNext()).isFalse(); + assertThat(response.exercises()).isEmpty(); + verify(exerciseRepository).findMyExercisesWithPaging(eq(myExerciseMember.getId()), any(Pageable.class)); + verify(exerciseRepository, never()).findMyUpcomingExercisesWithPaging(any(), any()); + verify(exerciseRepository, never()).findMyCompletedExercisesWithPaging(any(), any()); + } + + @Test + @DisplayName("UPCOMING 최신순은 예정 운동 리포지토리를 날짜 오름차순으로 호출한다") + void UPCOMING_최신순은_예정_운동_리포지토리를_날짜_오름차순으로_호출한다() { + // given + given(exerciseRepository.findMyUpcomingExercisesWithPaging(eq(myExerciseMember.getId()), argThat( + pageable -> matchesSort(pageable, Sort.Direction.ASC, Sort.Direction.ASC)))) + .willReturn(emptySlice(firstPage)); + + // when + exerciseMyQueryService.getMyExercises( + myExerciseMember.getId(), MyExerciseFilterType.UPCOMING, MyExerciseOrderType.LATEST, firstPage); + + // then + verify(exerciseRepository, never()).findMyExercisesWithPaging(any(), any()); + verify(exerciseRepository).findMyUpcomingExercisesWithPaging(eq(myExerciseMember.getId()), any(Pageable.class)); + verify(exerciseRepository, never()).findMyCompletedExercisesWithPaging(any(), any()); + } + + @Test + @DisplayName("COMPLETED 최신순은 완료 운동 리포지토리를 날짜 내림차순으로 호출한다") + void COMPLETED_최신순은_완료_운동_리포지토리를_날짜_내림차순으로_호출한다() { + // given + given(exerciseRepository.findMyCompletedExercisesWithPaging(eq(myExerciseMember.getId()), argThat( + pageable -> matchesSort(pageable, Sort.Direction.DESC, Sort.Direction.DESC)))) + .willReturn(emptySlice(firstPage)); + + // when + exerciseMyQueryService.getMyExercises( + myExerciseMember.getId(), MyExerciseFilterType.COMPLETED, MyExerciseOrderType.LATEST, firstPage); + + // then + verify(exerciseRepository, never()).findMyExercisesWithPaging(any(), any()); + verify(exerciseRepository, never()).findMyUpcomingExercisesWithPaging(any(), any()); + verify(exerciseRepository).findMyCompletedExercisesWithPaging(eq(myExerciseMember.getId()), any(Pageable.class)); + } + + @Test + @DisplayName("ALL 오래된순은 전체 운동 리포지토리를 날짜 오름차순으로 호출한다") + void ALL_오래된순은_전체_운동_리포지토리를_날짜_오름차순으로_호출한다() { + // given + given(exerciseRepository.findMyExercisesWithPaging(eq(myExerciseMember.getId()), argThat( + pageable -> matchesSort(pageable, Sort.Direction.ASC, Sort.Direction.ASC)))) + .willReturn(emptySlice(firstPage)); + + // when + exerciseMyQueryService.getMyExercises( + myExerciseMember.getId(), MyExerciseFilterType.ALL, MyExerciseOrderType.OLDEST, firstPage); + + // then + verify(exerciseRepository).findMyExercisesWithPaging(eq(myExerciseMember.getId()), any(Pageable.class)); + } + + @Test + @DisplayName("UPCOMING 오래된순은 예정 운동 리포지토리를 날짜 내림차순으로 호출한다") + void UPCOMING_오래된순은_예정_운동_리포지토리를_날짜_내림차순으로_호출한다() { + // given + given(exerciseRepository.findMyUpcomingExercisesWithPaging(eq(myExerciseMember.getId()), argThat( + pageable -> matchesSort(pageable, Sort.Direction.DESC, Sort.Direction.DESC)))) + .willReturn(emptySlice(firstPage)); + + // when + exerciseMyQueryService.getMyExercises( + myExerciseMember.getId(), MyExerciseFilterType.UPCOMING, MyExerciseOrderType.OLDEST, firstPage); + + // then + verify(exerciseRepository).findMyUpcomingExercisesWithPaging(eq(myExerciseMember.getId()), any(Pageable.class)); + } + + @Test + @DisplayName("COMPLETED 오래된순은 완료 운동 리포지토리를 날짜 오름차순으로 호출한다") + void COMPLETED_오래된순은_완료_운동_리포지토리를_날짜_오름차순으로_호출한다() { + // given + given(exerciseRepository.findMyCompletedExercisesWithPaging(eq(myExerciseMember.getId()), argThat( + pageable -> matchesSort(pageable, Sort.Direction.ASC, Sort.Direction.ASC)))) + .willReturn(emptySlice(firstPage)); + + // when + exerciseMyQueryService.getMyExercises( + myExerciseMember.getId(), MyExerciseFilterType.COMPLETED, MyExerciseOrderType.OLDEST, firstPage); + + // then + verify(exerciseRepository).findMyCompletedExercisesWithPaging(eq(myExerciseMember.getId()), any(Pageable.class)); + } + + @Test + @DisplayName("조회된 운동이 없으면 빈 응답을 반환한다") + void 조회된_운동이_없으면_빈_응답을_반환한다() { + // given + given(exerciseRepository.findMyExercisesWithPaging(eq(myExerciseMember.getId()), any(Pageable.class))) + .willReturn(emptySlice(firstPage)); + + // when + MyExerciseListDTO.Response response = exerciseMyQueryService.getMyExercises( + myExerciseMember.getId(), MyExerciseFilterType.ALL, MyExerciseOrderType.LATEST, firstPage); + + // then + assertThat(response.totalCount()).isZero(); + assertThat(response.hasNext()).isFalse(); + assertThat(response.exercises()).isEmpty(); + verify(exerciseRepository, never()).findExerciseParticipantCountsByExerciseIds(any()); + verify(exerciseBookmarkRepository, never()).findAllExerciseIdsByMemberIdAndExerciseIds(any(), any()); + } + + @Test + @DisplayName("조회 결과를 DTO 필드와 hasNext true로 매핑한다") + void 조회_결과를_DTO_필드와_hasNext_true로_매핑한다() { + // given + Slice exerciseSlice = sliceOf(List.of(futureLatestExercise, completedExercise), true, firstPage); + given(exerciseRepository.findMyExercisesWithPaging(eq(myExerciseMember.getId()), any(Pageable.class))) + .willReturn(exerciseSlice); + given(exerciseRepository.findExerciseParticipantCountsByExerciseIds( + List.of(futureLatestExercise.getId(), completedExercise.getId()))) + .willReturn(List.of( + new Object[]{futureLatestExercise.getId(), 3}, + new Object[]{completedExercise.getId(), 1} + )); + given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( + myExerciseMember.getId(), List.of(futureLatestExercise.getId(), completedExercise.getId()))) + .willReturn(List.of(futureLatestExercise.getId())); + + // when + MyExerciseListDTO.Response response = exerciseMyQueryService.getMyExercises( + myExerciseMember.getId(), MyExerciseFilterType.ALL, MyExerciseOrderType.LATEST, firstPage); + + // then + assertThat(response.totalCount()).isEqualTo(2); + assertThat(response.hasNext()).isTrue(); + assertThat(response.exercises()) + .extracting( + MyExerciseListDTO.ExerciseItem::exerciseId, + MyExerciseListDTO.ExerciseItem::partyId, + MyExerciseListDTO.ExerciseItem::partyName, + MyExerciseListDTO.ExerciseItem::isBookmarked, + MyExerciseListDTO.ExerciseItem::date, + MyExerciseListDTO.ExerciseItem::dayOfWeek, + MyExerciseListDTO.ExerciseItem::buildingName, + MyExerciseListDTO.ExerciseItem::startTime, + MyExerciseListDTO.ExerciseItem::endTime, + MyExerciseListDTO.ExerciseItem::currentParticipants, + MyExerciseListDTO.ExerciseItem::maxCapacity, + MyExerciseListDTO.ExerciseItem::isCompleted, + MyExerciseListDTO.ExerciseItem::partyGuestInviteAccept + ) + .containsExactly( + tuple(703L, 10L, "테스트 모임", true, + LocalDate.of(2099, 1, 10), "SATURDAY", "테스트 체육관", + LocalTime.of(7, 30), LocalTime.of(9, 0), 3, 20, false, true), + tuple(701L, 10L, "테스트 모임", false, + LocalDate.of(2024, 1, 5), "FRIDAY", "테스트 체육관", + LocalTime.of(9, 0), LocalTime.of(11, 0), 1, 18, true, false) + ); + } + + @Test + @DisplayName("조회 결과를 hasNext false로 매핑한다") + void 조회_결과를_hasNext_false로_매핑한다() { + // given + Pageable secondPage = PageRequest.of(1, 1); + Slice exerciseSlice = sliceOf(List.of(upcomingExercise), false, secondPage); + given(exerciseRepository.findMyExercisesWithPaging(eq(myExerciseMember.getId()), any(Pageable.class))) + .willReturn(exerciseSlice); + given(exerciseRepository.findExerciseParticipantCountsByExerciseIds(List.of(upcomingExercise.getId()))) + .willReturn(List.of()); + given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( + myExerciseMember.getId(), List.of(upcomingExercise.getId()))) + .willReturn(List.of()); + + // when + MyExerciseListDTO.Response response = exerciseMyQueryService.getMyExercises( + myExerciseMember.getId(), MyExerciseFilterType.ALL, MyExerciseOrderType.LATEST, secondPage); + + // then + assertThat(response.totalCount()).isEqualTo(1); + assertThat(response.hasNext()).isFalse(); + assertThat(response.exercises().get(0).exerciseId()).isEqualTo(upcomingExercise.getId()); + assertThat(response.exercises().get(0).isCompleted()).isFalse(); + } + } + + + private Exercise createMyExercise(long id, LocalDate date, LocalTime startTime, + LocalTime endTime, int maxCapacity, boolean partyGuestAccept) { + Exercise createdExercise = ExerciseFixture.createExerciseWithAddr(party, date, maxCapacity); + ReflectionTestUtils.setField(createdExercise, "id", id); + ReflectionTestUtils.setField(createdExercise, "startTime", startTime); + ReflectionTestUtils.setField(createdExercise, "endTime", endTime); + ReflectionTestUtils.setField(createdExercise, "partyGuestAccept", partyGuestAccept); + return createdExercise; + } + + private Slice emptySlice(Pageable pageable) { + return new SliceImpl<>(List.of(), pageable, false); + } + + private Slice sliceOf(List exercises, boolean hasNext, Pageable pageable) { + return new SliceImpl<>(exercises, pageable, hasNext); + } + + private boolean matchesSort(Pageable pageable, Sort.Direction dateDirection, Sort.Direction timeDirection) { + if (pageable.getPageNumber() != firstPage.getPageNumber() || pageable.getPageSize() != firstPage.getPageSize()) { + return false; + } + + List orders = pageable.getSort().stream().toList(); + return orders.size() == 2 + && orders.get(0).getProperty().equals("date") + && orders.get(0).getDirection() == dateDirection + && orders.get(1).getProperty().equals("startTime") + && orders.get(1).getDirection() == timeDirection; + } + } + + + +} diff --git a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseParticipantCountLookupServiceTest.java b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseParticipantCountLookupServiceTest.java new file mode 100644 index 000000000..2d040f6eb --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseParticipantCountLookupServiceTest.java @@ -0,0 +1,147 @@ +package umc.cockple.demo.domain.exercise.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; +import umc.cockple.demo.domain.exercise.service.query.lookup.ExerciseParticipantCountLookupService; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ExerciseParticipantCountLookupService") +class ExerciseParticipantCountLookupServiceTest { + + @InjectMocks + private ExerciseParticipantCountLookupService exerciseParticipantCountLookupService; + + @Mock + private ExerciseRepository exerciseRepository; + + @Nested + @DisplayName("getParticipantCountsByExerciseIds - 운동 ID 목록 기준 참여 인원 수 조회") + class GetParticipantCountsByExerciseIds { + + @Test + @DisplayName("운동 ID 목록이 비어있으면 빈 Map을 반환하고 Repository를 호출하지 않는다") + void emptyExerciseIds_returnsEmptyMapWithoutRepositoryCall() { + // when + Map result = exerciseParticipantCountLookupService + .getParticipantCountsByExerciseIds(List.of()); + + // then + assertThat(result).isEmpty(); + verifyNoInteractions(exerciseRepository); + } + + @Test + @DisplayName("Repository count 결과를 운동 ID별 참여 인원 수 Map으로 변환한다") + void repositoryCountRows_returnsParticipantCountMap() { + // given + List exerciseIds = List.of(1L, 2L); + given(exerciseRepository.findExerciseParticipantCountsByExerciseIds(exerciseIds)) + .willReturn(List.of( + new Object[]{1L, 3L}, + new Object[]{2L, 5L} + )); + + // when + Map result = exerciseParticipantCountLookupService + .getParticipantCountsByExerciseIds(exerciseIds); + + // then + assertThat(result).containsExactlyInAnyOrderEntriesOf(Map.of( + 1L, 3, + 2L, 5 + )); + verify(exerciseRepository).findExerciseParticipantCountsByExerciseIds(exerciseIds); + } + } + + @Nested + @DisplayName("getParticipantCountsByExerciseIdsAndDateRange - 운동 ID 목록과 날짜 범위 기준 참여 인원 수 조회") + class GetParticipantCountsByExerciseIdsAndDateRange { + + @Test + @DisplayName("운동 ID 목록이 비어있으면 빈 Map을 반환하고 Repository를 호출하지 않는다") + void emptyExerciseIds_returnsEmptyMapWithoutRepositoryCall() { + // when + Map result = exerciseParticipantCountLookupService + .getParticipantCountsByExerciseIdsAndDateRange( + List.of(), + LocalDate.of(2026, 6, 1), + LocalDate.of(2026, 6, 30) + ); + + // then + assertThat(result).isEmpty(); + verifyNoInteractions(exerciseRepository); + } + + @Test + @DisplayName("Repository count 결과를 운동 ID별 참여 인원 수 Map으로 변환한다") + void repositoryCountRows_returnsParticipantCountMap() { + // given + List exerciseIds = List.of(1L, 2L); + LocalDate start = LocalDate.of(2026, 6, 1); + LocalDate end = LocalDate.of(2026, 6, 30); + given(exerciseRepository.findExerciseParticipantCountsByExerciseIds(exerciseIds, start, end)) + .willReturn(List.of( + new Object[]{1L, 4L}, + new Object[]{2L, 6L} + )); + + // when + Map result = exerciseParticipantCountLookupService + .getParticipantCountsByExerciseIdsAndDateRange(exerciseIds, start, end); + + // then + assertThat(result).containsExactlyInAnyOrderEntriesOf(Map.of( + 1L, 4, + 2L, 6 + )); + verify(exerciseRepository).findExerciseParticipantCountsByExerciseIds(exerciseIds, start, end); + } + } + + @Nested + @DisplayName("getParticipantCountsByPartyIdAndDateRange - 모임과 날짜 범위 기준 참여 인원 수 조회") + class GetParticipantCountsByPartyIdAndDateRange { + + @Test + @DisplayName("Repository count 결과를 운동 ID별 참여 인원 수 Map으로 변환한다") + void repositoryCountRows_returnsParticipantCountMap() { + // given + Long partyId = 10L; + LocalDate start = LocalDate.of(2026, 6, 1); + LocalDate end = LocalDate.of(2026, 6, 30); + given(exerciseRepository.findExerciseParticipantCounts(partyId, start, end)) + .willReturn(List.of( + new Object[]{1L, 7L}, + new Object[]{2L, 8L} + )); + + // when + Map result = exerciseParticipantCountLookupService + .getParticipantCountsByPartyIdAndDateRange(partyId, start, end); + + // then + assertThat(result).containsExactlyInAnyOrderEntriesOf(Map.of( + 1L, 7, + 2L, 8 + )); + verify(exerciseRepository).findExerciseParticipantCounts(partyId, start, end); + } + } +} diff --git a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java deleted file mode 100644 index f71e0a6ef..000000000 --- a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java +++ /dev/null @@ -1,2357 +0,0 @@ -package umc.cockple.demo.domain.exercise.service; - -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.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Slice; -import org.springframework.data.domain.SliceImpl; -import org.springframework.data.domain.Sort; -import org.springframework.test.util.ReflectionTestUtils; -import umc.cockple.demo.domain.bookmark.repository.ExerciseBookmarkRepository; -import umc.cockple.demo.domain.exercise.converter.ExerciseConverter; -import umc.cockple.demo.domain.exercise.domain.Exercise; -import umc.cockple.demo.domain.exercise.domain.Guest; -import umc.cockple.demo.domain.exercise.dto.ExerciseBuildingDetailDTO; -import umc.cockple.demo.domain.exercise.dto.ExerciseDetailDTO; -import umc.cockple.demo.domain.exercise.dto.ExerciseEditDetailDTO; -import umc.cockple.demo.domain.exercise.dto.ExerciseMapBuildingsDTO; -import umc.cockple.demo.domain.exercise.dto.ExerciseRecommendationCalendarDTO; -import umc.cockple.demo.domain.exercise.dto.ExerciseMyGuestListDTO; -import umc.cockple.demo.domain.exercise.dto.MyExerciseCalendarDTO; -import umc.cockple.demo.domain.exercise.dto.MyExerciseListDTO; -import umc.cockple.demo.domain.exercise.dto.MyPartyExerciseCalendarDTO; -import umc.cockple.demo.domain.exercise.dto.MyPartyExerciseDTO; -import umc.cockple.demo.domain.exercise.dto.PartyExerciseCalendarDTO; -import umc.cockple.demo.domain.exercise.enums.MyExerciseFilterType; -import umc.cockple.demo.domain.exercise.enums.MyExerciseOrderType; -import umc.cockple.demo.domain.exercise.enums.MyPartyExerciseOrderType; -import umc.cockple.demo.domain.exercise.exception.ExerciseErrorCode; -import umc.cockple.demo.domain.exercise.exception.ExerciseException; -import umc.cockple.demo.domain.member.domain.MemberAddr; -import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; -import umc.cockple.demo.domain.exercise.repository.GuestRepository; -import umc.cockple.demo.domain.file.service.FileService; -import umc.cockple.demo.domain.member.domain.Member; -import umc.cockple.demo.domain.member.domain.MemberExercise; -import umc.cockple.demo.domain.member.domain.MemberParty; -import umc.cockple.demo.domain.member.repository.MemberExerciseRepository; -import umc.cockple.demo.domain.member.repository.MemberPartyRepository; -import umc.cockple.demo.domain.member.repository.MemberRepository; -import umc.cockple.demo.domain.party.domain.Party; -import umc.cockple.demo.domain.party.enums.ActivityTime; -import umc.cockple.demo.domain.party.enums.ParticipationType; -import umc.cockple.demo.domain.party.enums.PartyStatus; -import umc.cockple.demo.domain.party.exception.PartyErrorCode; -import umc.cockple.demo.domain.party.exception.PartyException; -import umc.cockple.demo.domain.party.repository.PartyRepository; -import umc.cockple.demo.global.enums.Gender; -import umc.cockple.demo.global.enums.Level; -import umc.cockple.demo.global.enums.Role; -import umc.cockple.demo.support.ExerciseCalendarTestHelper; -import umc.cockple.demo.support.fixture.ExerciseFixture; -import umc.cockple.demo.support.fixture.GuestFixture; -import umc.cockple.demo.support.fixture.MemberAddrFixture; -import umc.cockple.demo.support.fixture.MemberFixture; -import umc.cockple.demo.support.fixture.PartyFixture; - -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.LocalTime; -import java.time.YearMonth; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.assertj.core.groups.Tuple.tuple; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.argThat; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; - -@ExtendWith(MockitoExtension.class) -@DisplayName("ExerciseQueryService") -class ExerciseQueryServiceTest { - - @InjectMocks - private ExerciseQueryService exerciseQueryService; - - @Mock private ExerciseRepository exerciseRepository; - @Mock private MemberRepository memberRepository; - @Mock private MemberPartyRepository memberPartyRepository; - @Mock private MemberExerciseRepository memberExerciseRepository; - @Mock private GuestRepository guestRepository; - @Mock private PartyRepository partyRepository; - @Mock private ExerciseBookmarkRepository exerciseBookmarkRepository; - @Mock private FileService fileService; - - private ExerciseConverter exerciseConverter; - - private Member manager; - private Party party; - private Exercise exercise; - - @BeforeEach - void setUp() { - exerciseConverter = new ExerciseConverter(fileService); - ReflectionTestUtils.setField(exerciseQueryService, "exerciseConverter", exerciseConverter); - - manager = MemberFixture.createMember("모임장", Gender.MALE, Level.A, 1001L); - ReflectionTestUtils.setField(manager, "id", 1L); - - party = PartyFixture.createParty("테스트 모임", manager.getId(), - PartyFixture.createPartyAddr("서울특별시", "강남구")); - ReflectionTestUtils.setField(party, "id", 10L); - - exercise = ExerciseFixture.createExercise(party, LocalDate.now().minusDays(1)); - ReflectionTestUtils.setField(exercise, "id", 100L); - - ReflectionTestUtils.setField(exercise, "exerciseAddr", ExerciseFixture.createExerciseAddr()); - } - - @Nested - @DisplayName("getExerciseDetail") - class GetExerciseDetail { - - @Nested - @DisplayName("성공 케이스") - class Success { - - @Test - @DisplayName("모임장이면_isManager_true로_반환된다") - void 모임장이면_isManager_true로_반환된다() { - // given - given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) - .willReturn(Optional.of(exercise)); - given(memberRepository.findById(manager.getId())) - .willReturn(Optional.of(manager)); - given(memberExerciseRepository.findByExerciseIdWithMemberAndProfile(exercise.getId())) - .willReturn(List.of()); - given(guestRepository.findByExerciseId(exercise.getId())) - .willReturn(List.of()); - given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole( - party.getId(), manager.getId(), Role.PARTY_MANAGER)) - .willReturn(true); - - // when - ExerciseDetailDTO.Response response = exerciseQueryService.getExerciseDetail( - exercise.getId(), manager.getId()); - - // then - assertThat(response.isManager()).isTrue(); - } - - @Test - @DisplayName("부모임장이_조회하면_isManager_false로_반환된다") - void 부모임장이_조회하면_isManager_false로_반환된다() { - // given - Member subManager = MemberFixture.createMember("부모임장", Gender.FEMALE, Level.B, 2003L); - ReflectionTestUtils.setField(subManager, "id", 21L); - - given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) - .willReturn(Optional.of(exercise)); - given(memberRepository.findById(subManager.getId())) - .willReturn(Optional.of(subManager)); - given(memberExerciseRepository.findByExerciseIdWithMemberAndProfile(exercise.getId())) - .willReturn(List.of()); - given(guestRepository.findByExerciseId(exercise.getId())) - .willReturn(List.of()); - given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole( - party.getId(), subManager.getId(), Role.PARTY_MANAGER)) - .willReturn(false); - - // when - ExerciseDetailDTO.Response response = exerciseQueryService.getExerciseDetail( - exercise.getId(), subManager.getId()); - - // then - assertThat(response.isManager()).isFalse(); - } - - @Test - @DisplayName("모임_일반_멤버여도_isManager_false로_반환된다") - void 모임_일반_멤버여도_isManager_false로_반환된다() { - // given - Member normalMember = MemberFixture.createMember("일반멤버", Gender.FEMALE, Level.B, 2002L); - ReflectionTestUtils.setField(normalMember, "id", 2L); - - given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) - .willReturn(Optional.of(exercise)); - given(memberRepository.findById(normalMember.getId())) - .willReturn(Optional.of(normalMember)); - given(memberExerciseRepository.findByExerciseIdWithMemberAndProfile(exercise.getId())) - .willReturn(List.of()); - given(guestRepository.findByExerciseId(exercise.getId())) - .willReturn(List.of()); - given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole( - party.getId(), normalMember.getId(), Role.PARTY_MANAGER)) - .willReturn(false); - - // when - ExerciseDetailDTO.Response response = exerciseQueryService.getExerciseDetail( - exercise.getId(), normalMember.getId()); - - // then - assertThat(response.isManager()).isFalse(); - } - - @Test - @DisplayName("모임_외부_회원도_상세_조회에_성공하고_isManager_false로_반환된다") - void 모임_외부_회원도_상세_조회에_성공하고_isManager_false로_반환된다() { - // given - Member outsider = MemberFixture.createMember("외부회원", Gender.MALE, Level.C, 3003L); - ReflectionTestUtils.setField(outsider, "id", 3L); - - given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) - .willReturn(Optional.of(exercise)); - given(memberRepository.findById(outsider.getId())) - .willReturn(Optional.of(outsider)); - given(memberExerciseRepository.findByExerciseIdWithMemberAndProfile(exercise.getId())) - .willReturn(List.of()); - given(guestRepository.findByExerciseId(exercise.getId())) - .willReturn(List.of()); - given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole( - party.getId(), outsider.getId(), Role.PARTY_MANAGER)) - .willReturn(false); - - // when - ExerciseDetailDTO.Response response = exerciseQueryService.getExerciseDetail( - exercise.getId(), outsider.getId()); - - // then - assertThat(response.isManager()).isFalse(); - assertThat(response.info().buildingName()).isEqualTo("테스트 체육관"); - } - - @Test - @DisplayName("탈퇴_회원은_isWithdrawn_true로_반환된다") - void 탈퇴_회원은_isWithdrawn_true로_반환된다() { - // given - Member withdrawnMember = MemberFixture.createWithdrawnMember("탈퇴회원", "탈퇴닉네임", 9999L); - ReflectionTestUtils.setField(withdrawnMember, "id", 99L); - - MemberExercise memberExercise = MemberFixture.createMemberExercise(withdrawnMember, exercise); - - given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) - .willReturn(Optional.of(exercise)); - given(memberRepository.findById(manager.getId())) - .willReturn(Optional.of(manager)); - given(memberExerciseRepository.findByExerciseIdWithMemberAndProfile(exercise.getId())) - .willReturn(List.of(memberExercise)); - given(guestRepository.findByExerciseId(exercise.getId())) - .willReturn(List.of()); - given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole( - party.getId(), manager.getId(), Role.PARTY_MANAGER)) - .willReturn(true); - given(memberPartyRepository.findMemberRolesByPartyAndMembers( - party.getId(), List.of(withdrawnMember.getId()))) - .willReturn(List.of()); - - // when - ExerciseDetailDTO.Response response = exerciseQueryService.getExerciseDetail( - exercise.getId(), manager.getId()); - - // then - List participants = response.participants().list(); - assertThat(participants).hasSize(1); - assertThat(participants.get(0).isWithdrawn()).isTrue(); - } - - @Test - @DisplayName("활성_회원은_isWithdrawn_false로_반환된다") - void 활성_회원은_isWithdrawn_false로_반환된다() { - // given - Member activeMember = MemberFixture.createMember("활성회원", Gender.FEMALE, Level.B, 2002L); - ReflectionTestUtils.setField(activeMember, "id", 2L); - - MemberExercise memberExercise = MemberFixture.createMemberExercise(activeMember, exercise); - - MemberParty memberParty = MemberFixture.createMemberParty(party, activeMember, Role.PARTY_MEMBER); - - given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) - .willReturn(Optional.of(exercise)); - given(memberRepository.findById(manager.getId())) - .willReturn(Optional.of(manager)); - given(memberExerciseRepository.findByExerciseIdWithMemberAndProfile(exercise.getId())) - .willReturn(List.of(memberExercise)); - given(guestRepository.findByExerciseId(exercise.getId())) - .willReturn(List.of()); - given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole( - party.getId(), manager.getId(), Role.PARTY_MANAGER)) - .willReturn(true); - given(memberPartyRepository.findMemberRolesByPartyAndMembers( - party.getId(), List.of(activeMember.getId()))) - .willReturn(List.of(memberParty)); - - // when - ExerciseDetailDTO.Response response = exerciseQueryService.getExerciseDetail( - exercise.getId(), manager.getId()); - - // then - List participants = response.participants().list(); - assertThat(participants).hasSize(1); - assertThat(participants.get(0).isWithdrawn()).isFalse(); - } - - @Test - @DisplayName("게스트는_isWithdrawn_false로_반환된다") - void 게스트는_isWithdrawn_false로_반환된다() { - // given - Guest guest = GuestFixture.createGuest(exercise, manager.getId()); - ReflectionTestUtils.setField(guest, "id", 70L); - - given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) - .willReturn(Optional.of(exercise)); - given(memberRepository.findById(manager.getId())) - .willReturn(Optional.of(manager)); - given(memberExerciseRepository.findByExerciseIdWithMemberAndProfile(exercise.getId())) - .willReturn(List.of()); - given(guestRepository.findByExerciseId(exercise.getId())) - .willReturn(List.of(guest)); - given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole( - party.getId(), manager.getId(), Role.PARTY_MANAGER)) - .willReturn(true); - given(memberRepository.findMemberNamesByIds(any())) - .willReturn(Map.of(manager.getId(), "모임장")); - - // when - ExerciseDetailDTO.Response response = exerciseQueryService.getExerciseDetail( - exercise.getId(), manager.getId()); - - // then - List participants = response.participants().list(); - assertThat(participants).hasSize(1); - assertThat(participants.get(0).isWithdrawn()).isFalse(); - assertThat(participants.get(0).partyPosition()).isNull(); - } - - @Test - @DisplayName("참가자_유형별_partyPosition이_올바르게_반환된다") - void 참가자_유형별_partyPosition이_올바르게_반환된다() { - // given - Member subManager = MemberFixture.createMember("부모임장", Gender.FEMALE, Level.B, 5003L); - ReflectionTestUtils.setField(subManager, "id", 31L); - - Member normalMember = MemberFixture.createMember("일반멤버", Gender.MALE, Level.C, 5004L); - ReflectionTestUtils.setField(normalMember, "id", 32L); - - Member outsider = MemberFixture.createMember("외부회원", Gender.FEMALE, Level.B, 5005L); - ReflectionTestUtils.setField(outsider, "id", 33L); - - MemberExercise managerExercise = MemberFixture.createMemberExercise(manager, exercise); - ReflectionTestUtils.setField(managerExercise, "createdAt", LocalDateTime.now().minusMinutes(5)); - - MemberExercise subManagerExercise = MemberFixture.createMemberExercise(subManager, exercise); - ReflectionTestUtils.setField(subManagerExercise, "createdAt", LocalDateTime.now().minusMinutes(4)); - - MemberExercise normalMemberExercise = MemberFixture.createMemberExercise(normalMember, exercise); - ReflectionTestUtils.setField(normalMemberExercise, "createdAt", LocalDateTime.now().minusMinutes(3)); - - MemberExercise outsiderExercise = MemberFixture.createExternalMemberExercise(outsider, exercise); - ReflectionTestUtils.setField(outsiderExercise, "createdAt", LocalDateTime.now().minusMinutes(2)); - - Guest guest = GuestFixture.createGuest(exercise, manager.getId()); - ReflectionTestUtils.setField(guest, "id", 71L); - ReflectionTestUtils.setField(guest, "createdAt", LocalDateTime.now().minusMinutes(1)); - - MemberParty managerParty = MemberFixture.createMemberParty(party, manager, Role.PARTY_MANAGER); - MemberParty subManagerParty = MemberFixture.createMemberParty(party, subManager, Role.PARTY_SUBMANAGER); - MemberParty memberParty = MemberFixture.createMemberParty(party, normalMember, Role.PARTY_MEMBER); - - given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) - .willReturn(Optional.of(exercise)); - given(memberRepository.findById(manager.getId())) - .willReturn(Optional.of(manager)); - given(memberExerciseRepository.findByExerciseIdWithMemberAndProfile(exercise.getId())) - .willReturn(List.of(managerExercise, subManagerExercise, normalMemberExercise, outsiderExercise)); - given(guestRepository.findByExerciseId(exercise.getId())) - .willReturn(List.of(guest)); - given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole( - party.getId(), manager.getId(), Role.PARTY_MANAGER)) - .willReturn(true); - given(memberPartyRepository.findMemberRolesByPartyAndMembers( - party.getId(), List.of(manager.getId(), subManager.getId(), normalMember.getId(), outsider.getId()))) - .willReturn(List.of(managerParty, subManagerParty, memberParty)); - given(memberRepository.findMemberNamesByIds(any())) - .willReturn(Map.of(manager.getId(), "모임장")); - - // when - ExerciseDetailDTO.Response response = exerciseQueryService.getExerciseDetail( - exercise.getId(), manager.getId()); - - // then - assertThat(response.participants().list()) - .extracting( - ExerciseDetailDTO.ParticipantInfo::name, - ExerciseDetailDTO.ParticipantInfo::participantType, - ExerciseDetailDTO.ParticipantInfo::partyPosition) - .containsExactly( - tuple("모임장", "PARTY_MEMBER", "PARTY_MANAGER"), - tuple("부모임장", "PARTY_MEMBER", "PARTY_SUBMANAGER"), - tuple("일반멤버", "PARTY_MEMBER", "PARTY_MEMBER"), - tuple("외부회원", "EXTERNAL_PARTICIPANT", null), - tuple("게스트", "GUEST", null) - ); - } - - @Test - @DisplayName("정원_초과_참가자는_대기자_목록으로_반환된다") - void 정원_초과_참가자는_대기자_목록으로_반환된다() { - // given - ReflectionTestUtils.setField(exercise, "maxCapacity", 1); - - Member firstMember = MemberFixture.createMember("첫번째", Gender.MALE, Level.A, 3001L); - ReflectionTestUtils.setField(firstMember, "id", 3L); - - Member secondMember = MemberFixture.createMember("두번째", Gender.FEMALE, Level.B, 3002L); - ReflectionTestUtils.setField(secondMember, "id", 4L); - - MemberExercise first = MemberFixture.createMemberExercise(firstMember, exercise); - ReflectionTestUtils.setField(first, "createdAt", LocalDateTime.now().minusMinutes(10)); - - MemberExercise second = MemberFixture.createMemberExercise(secondMember, exercise); - ReflectionTestUtils.setField(second, "createdAt", LocalDateTime.now()); - - MemberParty firstParty = MemberFixture.createMemberParty(party, firstMember, Role.PARTY_MEMBER); - MemberParty secondParty = MemberFixture.createMemberParty(party, secondMember, Role.PARTY_MEMBER); - - given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) - .willReturn(Optional.of(exercise)); - given(memberRepository.findById(manager.getId())) - .willReturn(Optional.of(manager)); - given(memberExerciseRepository.findByExerciseIdWithMemberAndProfile(exercise.getId())) - .willReturn(List.of(first, second)); - given(guestRepository.findByExerciseId(exercise.getId())) - .willReturn(List.of()); - given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole( - party.getId(), manager.getId(), Role.PARTY_MANAGER)) - .willReturn(true); - given(memberPartyRepository.findMemberRolesByPartyAndMembers( - party.getId(), List.of(firstMember.getId(), secondMember.getId()))) - .willReturn(List.of(firstParty, secondParty)); - - // when - ExerciseDetailDTO.Response response = exerciseQueryService.getExerciseDetail( - exercise.getId(), manager.getId()); - - // then - assertThat(response.participants().list()).hasSize(1); - assertThat(response.waiting().list()).hasSize(1); - assertThat(response.waiting().currentWaitingCount()).isEqualTo(1); - } - - @Test - @DisplayName("게스트_참가자는_participantType이_GUEST이고_inviterName이_반환된다") - void 게스트_참가자는_participantType이_GUEST이고_inviterName이_반환된다() { - // given - Guest guest = GuestFixture.createGuest(exercise, manager.getId()); - ReflectionTestUtils.setField(guest, "id", 50L); - - given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) - .willReturn(Optional.of(exercise)); - given(memberRepository.findById(manager.getId())) - .willReturn(Optional.of(manager)); - given(memberExerciseRepository.findByExerciseIdWithMemberAndProfile(exercise.getId())) - .willReturn(List.of()); - given(guestRepository.findByExerciseId(exercise.getId())) - .willReturn(List.of(guest)); - given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole( - party.getId(), manager.getId(), Role.PARTY_MANAGER)) - .willReturn(true); - given(memberRepository.findMemberNamesByIds(any())) - .willReturn(Map.of(manager.getId(), "모임장")); - - // when - ExerciseDetailDTO.Response response = exerciseQueryService.getExerciseDetail( - exercise.getId(), manager.getId()); - - // then - List participants = response.participants().list(); - assertThat(participants).hasSize(1); - assertThat(participants.get(0).participantType()).isEqualTo("GUEST"); - assertThat(participants.get(0).inviterName()).isEqualTo("모임장"); - } - - @Test - @DisplayName("먼저_가입한_참가자가_더_낮은_participantNumber를_받는다") - void 먼저_가입한_참가자가_더_낮은_participantNumber를_받는다() { - // given - Member firstMember = MemberFixture.createMember("첫번째", Gender.MALE, Level.A, 5001L); - ReflectionTestUtils.setField(firstMember, "id", 7L); - - Member secondMember = MemberFixture.createMember("두번째", Gender.FEMALE, Level.B, 5002L); - ReflectionTestUtils.setField(secondMember, "id", 8L); - - MemberExercise first = MemberFixture.createMemberExercise(firstMember, exercise); - ReflectionTestUtils.setField(first, "createdAt", LocalDateTime.now().minusMinutes(10)); - - MemberExercise second = MemberFixture.createMemberExercise(secondMember, exercise); - ReflectionTestUtils.setField(second, "createdAt", LocalDateTime.now()); - - MemberParty firstParty = MemberFixture.createMemberParty(party, firstMember, Role.PARTY_MEMBER); - MemberParty secondParty = MemberFixture.createMemberParty(party, secondMember, Role.PARTY_MEMBER); - - given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) - .willReturn(Optional.of(exercise)); - given(memberRepository.findById(manager.getId())) - .willReturn(Optional.of(manager)); - given(memberExerciseRepository.findByExerciseIdWithMemberAndProfile(exercise.getId())) - .willReturn(List.of(first, second)); - given(guestRepository.findByExerciseId(exercise.getId())) - .willReturn(List.of()); - given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole( - party.getId(), manager.getId(), Role.PARTY_MANAGER)) - .willReturn(true); - given(memberPartyRepository.findMemberRolesByPartyAndMembers( - party.getId(), List.of(firstMember.getId(), secondMember.getId()))) - .willReturn(List.of(firstParty, secondParty)); - - // when - ExerciseDetailDTO.Response response = exerciseQueryService.getExerciseDetail( - exercise.getId(), manager.getId()); - - // then - List participants = response.participants().list(); - assertThat(participants).hasSize(2); - assertThat(participants.get(0).participantNumber()).isEqualTo(1); - assertThat(participants.get(0).name()).isEqualTo("첫번째"); - assertThat(participants.get(1).participantNumber()).isEqualTo(2); - assertThat(participants.get(1).name()).isEqualTo("두번째"); - } - - @Test - @DisplayName("대기자_성별_카운트가_올바르게_계산된다") - void 대기자_성별_카운트가_올바르게_계산된다() { - // given - ReflectionTestUtils.setField(exercise, "maxCapacity", 1); - - Member maleMember = MemberFixture.createMember("남성", Gender.MALE, Level.A, 6001L); - ReflectionTestUtils.setField(maleMember, "id", 11L); - - Member femaleMember = MemberFixture.createMember("여성", Gender.FEMALE, Level.B, 6002L); - ReflectionTestUtils.setField(femaleMember, "id", 12L); - - MemberExercise first = MemberFixture.createMemberExercise(maleMember, exercise); - ReflectionTestUtils.setField(first, "createdAt", LocalDateTime.now().minusMinutes(10)); - - MemberExercise second = MemberFixture.createMemberExercise(femaleMember, exercise); - ReflectionTestUtils.setField(second, "createdAt", LocalDateTime.now()); - - MemberParty maleParty = MemberFixture.createMemberParty(party, maleMember, Role.PARTY_MEMBER); - MemberParty femaleParty = MemberFixture.createMemberParty(party, femaleMember, Role.PARTY_MEMBER); - - given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) - .willReturn(Optional.of(exercise)); - given(memberRepository.findById(manager.getId())) - .willReturn(Optional.of(manager)); - given(memberExerciseRepository.findByExerciseIdWithMemberAndProfile(exercise.getId())) - .willReturn(List.of(first, second)); - given(guestRepository.findByExerciseId(exercise.getId())) - .willReturn(List.of()); - given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole( - party.getId(), manager.getId(), Role.PARTY_MANAGER)) - .willReturn(true); - given(memberPartyRepository.findMemberRolesByPartyAndMembers( - party.getId(), List.of(maleMember.getId(), femaleMember.getId()))) - .willReturn(List.of(maleParty, femaleParty)); - - // when - ExerciseDetailDTO.Response response = exerciseQueryService.getExerciseDetail( - exercise.getId(), manager.getId()); - - // then - assertThat(response.participants().manCount()).isEqualTo(1); - assertThat(response.participants().womenCount()).isZero(); - assertThat(response.waiting().manCount()).isZero(); - assertThat(response.waiting().womenCount()).isEqualTo(1); - } - - @Test - @DisplayName("참가자_성별_카운트가_올바르게_계산된다") - void 참가자_성별_카운트가_올바르게_계산된다() { - // given - Member maleMember = MemberFixture.createMember("남성", Gender.MALE, Level.A, 4001L); - ReflectionTestUtils.setField(maleMember, "id", 5L); - - Member femaleMember = MemberFixture.createMember("여성", Gender.FEMALE, Level.B, 4002L); - ReflectionTestUtils.setField(femaleMember, "id", 6L); - - MemberExercise maleExercise = MemberFixture.createMemberExercise(maleMember, exercise); - ReflectionTestUtils.setField(maleExercise, "createdAt", LocalDateTime.now().minusMinutes(5)); - - MemberExercise femaleExercise = MemberFixture.createMemberExercise(femaleMember, exercise); - ReflectionTestUtils.setField(femaleExercise, "createdAt", LocalDateTime.now()); - - MemberParty maleParty = MemberFixture.createMemberParty(party, maleMember, Role.PARTY_MEMBER); - MemberParty femaleParty = MemberFixture.createMemberParty(party, femaleMember, Role.PARTY_MEMBER); - - given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) - .willReturn(Optional.of(exercise)); - given(memberRepository.findById(manager.getId())) - .willReturn(Optional.of(manager)); - given(memberExerciseRepository.findByExerciseIdWithMemberAndProfile(exercise.getId())) - .willReturn(List.of(maleExercise, femaleExercise)); - given(guestRepository.findByExerciseId(exercise.getId())) - .willReturn(List.of()); - given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole( - party.getId(), manager.getId(), Role.PARTY_MANAGER)) - .willReturn(true); - given(memberPartyRepository.findMemberRolesByPartyAndMembers( - party.getId(), List.of(maleMember.getId(), femaleMember.getId()))) - .willReturn(List.of(maleParty, femaleParty)); - - // when - ExerciseDetailDTO.Response response = exerciseQueryService.getExerciseDetail( - exercise.getId(), manager.getId()); - - // then - assertThat(response.participants().manCount()).isEqualTo(1); - assertThat(response.participants().womenCount()).isEqualTo(1); - } - } - - @Nested - @DisplayName("실패 케이스") - class Failure { - - @Test - @DisplayName("존재하지_않는_운동이면_예외를_던진다") - void 존재하지_않는_운동이면_예외를_던진다() { - // given - given(exerciseRepository.findExerciseWithBasicInfo(999L)) - .willReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> exerciseQueryService.getExerciseDetail(999L, manager.getId())) - .isInstanceOf(ExerciseException.class) - .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.EXERCISE_NOT_FOUND); - } - - @Test - @DisplayName("존재하지_않는_멤버면_예외를_던진다") - void 존재하지_않는_멤버면_예외를_던진다() { - // given - given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) - .willReturn(Optional.of(exercise)); - given(memberRepository.findById(999L)) - .willReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> exerciseQueryService.getExerciseDetail(exercise.getId(), 999L)) - .isInstanceOf(ExerciseException.class) - .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.MEMBER_NOT_FOUND); - } - } - } - - @Nested - @DisplayName("getExerciseForEdit") - class GetExerciseForEdit { - - @Nested - @DisplayName("성공 케이스") - class Success { - - @Test - @DisplayName("운동 수정용 상세 정보의 모든 필드가 올바르게 반환된다") - void 운동_수정용_상세_정보의_모든_필드가_올바르게_반환된다() { - // given - LocalDate targetDate = LocalDate.of(2026, 3, 24); - Exercise exerciseForEdit = ExerciseFixture.createExerciseForEdit(party, targetDate); - ReflectionTestUtils.setField(exerciseForEdit, "id", 101L); - - given(exerciseRepository.findExerciseWithBasicInfo(exerciseForEdit.getId())) - .willReturn(Optional.of(exerciseForEdit)); - - // when - ExerciseEditDetailDTO.Response response = exerciseQueryService.getExerciseForEdit( - exerciseForEdit.getId(), manager.getId()); - - // then - assertThat(response.date()).isEqualTo(targetDate); - assertThat(response.buildingName()).isEqualTo("테스트 체육관"); - assertThat(response.roadAddress()).isEqualTo("서울특별시 강남구 테헤란로 1"); - assertThat(response.latitude()).isEqualTo(37.5); - assertThat(response.longitude()).isEqualTo(127.0); - assertThat(response.startTime()).isEqualTo(LocalTime.of(10, 0)); - assertThat(response.endTime()).isEqualTo(LocalTime.of(12, 30)); - assertThat(response.maxCapacity()).isEqualTo(18); - assertThat(response.allowMemberGuestsInvitation()).isTrue(); - assertThat(response.allowExternalGuests()).isFalse(); - assertThat(response.notice()).isEqualTo("수정 공지사항"); - } - } - - @Nested - @DisplayName("실패 케이스") - class Failure { - - @Test - @DisplayName("존재하지_않는_운동이면_예외를_던진다") - void 존재하지_않는_운동이면_예외를_던진다() { - // given - given(exerciseRepository.findExerciseWithBasicInfo(999L)) - .willReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> exerciseQueryService.getExerciseForEdit(999L, manager.getId())) - .isInstanceOf(ExerciseException.class) - .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.EXERCISE_NOT_FOUND); - } - } - } - - @Nested - @DisplayName("getMyInvitedGuests") - class GetMyInvitedGuests { - - @Nested - @DisplayName("성공 케이스") - class Success { - - @Test - @DisplayName("내가_초대한_게스트만_참가번호와_대기상태와_함께_반환된다") - void 내가_초대한_게스트만_참가번호와_대기상태와_함께_반환된다() { - // given - ReflectionTestUtils.setField(exercise, "maxCapacity", 1); - - Guest myFirstGuest = GuestFixture.createGuest(exercise, manager.getId(), "내게스트1", Gender.MALE); - ReflectionTestUtils.setField(myFirstGuest, "id", 201L); - ReflectionTestUtils.setField(myFirstGuest, "createdAt", LocalDateTime.now().minusMinutes(3)); - - Guest otherInvitedGuest = GuestFixture.createGuest(exercise, 2L, "다른사람게스트", Gender.MALE); - ReflectionTestUtils.setField(otherInvitedGuest, "id", 202L); - ReflectionTestUtils.setField(otherInvitedGuest, "createdAt", LocalDateTime.now().minusMinutes(2)); - - Guest mySecondGuest = GuestFixture.createGuest(exercise, manager.getId(), "내게스트2", Gender.FEMALE); - ReflectionTestUtils.setField(mySecondGuest, "id", 203L); - ReflectionTestUtils.setField(mySecondGuest, "createdAt", LocalDateTime.now().minusMinutes(1)); - - given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) - .willReturn(Optional.of(exercise)); - given(memberRepository.findById(manager.getId())) - .willReturn(Optional.of(manager)); - given(guestRepository.findByExerciseIdAndInviterId(exercise.getId(), manager.getId())) - .willReturn(List.of(myFirstGuest, mySecondGuest)); - given(memberExerciseRepository.findByExerciseIdWithMemberAndProfile(exercise.getId())) - .willReturn(List.of()); - given(guestRepository.findByExerciseId(exercise.getId())) - .willReturn(List.of(myFirstGuest, otherInvitedGuest, mySecondGuest)); - - // when - ExerciseMyGuestListDTO.Response response = exerciseQueryService.getMyInvitedGuests( - exercise.getId(), manager.getId()); - - // then - assertThat(response.totalCount()).isEqualTo(2); - assertThat(response.maleCount()).isEqualTo(1); - assertThat(response.femaleCount()).isEqualTo(1); - assertThat(response.list()) - .extracting( - ExerciseMyGuestListDTO.GuestInfo::guestId, - ExerciseMyGuestListDTO.GuestInfo::isWaiting, - ExerciseMyGuestListDTO.GuestInfo::participantNumber, - ExerciseMyGuestListDTO.GuestInfo::name, - ExerciseMyGuestListDTO.GuestInfo::gender, - ExerciseMyGuestListDTO.GuestInfo::level, - ExerciseMyGuestListDTO.GuestInfo::inviterName - ) - .containsExactly( - tuple(201L, false, 1, "내게스트1", Gender.MALE, Level.B, manager.getMemberName()), - tuple(203L, true, 2, "내게스트2", Gender.FEMALE, Level.B, manager.getMemberName()) - ); - } - - @Test - @DisplayName("초대한_게스트가_없으면_빈_응답을_반환한다") - void 초대한_게스트가_없으면_빈_응답을_반환한다() { - // given - given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) - .willReturn(Optional.of(exercise)); - given(memberRepository.findById(manager.getId())) - .willReturn(Optional.of(manager)); - given(guestRepository.findByExerciseIdAndInviterId(exercise.getId(), manager.getId())) - .willReturn(List.of()); - - // when - ExerciseMyGuestListDTO.Response response = exerciseQueryService.getMyInvitedGuests( - exercise.getId(), manager.getId()); - - // then - assertThat(response.totalCount()).isZero(); - assertThat(response.maleCount()).isZero(); - assertThat(response.femaleCount()).isZero(); - assertThat(response.list()).isEmpty(); - } - } - - @Nested - @DisplayName("실패 케이스") - class Failure { - - @Test - @DisplayName("존재하지_않는_운동이면_예외를_던진다") - void 존재하지_않는_운동이면_예외를_던진다() { - // given - given(exerciseRepository.findExerciseWithBasicInfo(999L)) - .willReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> exerciseQueryService.getMyInvitedGuests(999L, manager.getId())) - .isInstanceOf(ExerciseException.class) - .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.EXERCISE_NOT_FOUND); - } - - @Test - @DisplayName("존재하지_않는_멤버면_예외를_던진다") - void 존재하지_않는_멤버면_예외를_던진다() { - // given - given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) - .willReturn(Optional.of(exercise)); - given(memberRepository.findById(999L)) - .willReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> exerciseQueryService.getMyInvitedGuests(exercise.getId(), 999L)) - .isInstanceOf(ExerciseException.class) - .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.MEMBER_NOT_FOUND); - } - } - } - - @Nested - @DisplayName("getPartyExerciseCalendar") - class GetPartyExerciseCalendar { - - private Member partyMember; - private Member outsiderMember; - private LocalDate startDate; - private LocalDate endDate; - - @BeforeEach - void setUp() { - partyMember = MemberFixture.createMember("파티멤버", Gender.FEMALE, Level.B, 3001L); - ReflectionTestUtils.setField(partyMember, "id", 2L); - - outsiderMember = MemberFixture.createMember("외부멤버", Gender.MALE, Level.C, 3002L); - ReflectionTestUtils.setField(outsiderMember, "id", 3L); - - party.addLevel(Gender.FEMALE, Level.B); - party.addLevel(Gender.MALE, Level.A); - - startDate = LocalDate.of(2026, 3, 23); - endDate = LocalDate.of(2026, 3, 29); - - ReflectionTestUtils.setField(exercise, "date", LocalDate.of(2026, 3, 24)); - } - - @Nested - @DisplayName("성공 케이스") - class Success { - - @Test - @DisplayName("모임 운동 캘린더를 주차별_일자별로 반환한다") - void 모임_운동_캘린더를_주차별_일자별로_반환한다() { - // given - given(partyRepository.findByIdWithLevels(party.getId())) - .willReturn(Optional.of(party)); - given(memberRepository.findById(partyMember.getId())) - .willReturn(Optional.of(partyMember)); - given(memberPartyRepository.existsByPartyAndMember(party, partyMember)) - .willReturn(true); - given(exerciseRepository.findByPartyIdAndDateRange(party.getId(), startDate, endDate)) - .willReturn(List.of(exercise)); - given(exerciseRepository.findExerciseParticipantCounts(party.getId(), startDate, endDate)) - .willReturn(java.util.Collections.singletonList(new Object[]{exercise.getId(), 2})); - given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( - partyMember.getId(), List.of(exercise.getId()))) - .willReturn(List.of(exercise.getId())); - given(memberExerciseRepository.findAllExerciseIdsByMemberAndExerciseIds( - partyMember.getId(), List.of(exercise.getId()))) - .willReturn(List.of(exercise.getId())); - - // when - PartyExerciseCalendarDTO.Response response = exerciseQueryService.getPartyExerciseCalendar( - party.getId(), partyMember.getId(), startDate, endDate); - - // then - assertThat(response.startDate()).isEqualTo(startDate); - assertThat(response.endDate()).isEqualTo(endDate); - assertThat(response.isMember()).isTrue(); - assertThat(response.partyName()).isEqualTo(party.getPartyName()); - assertThat(response.weeks()).hasSize(1); - assertThat(response.weeks().get(0).weekStartDate()).isEqualTo(startDate); - assertThat(response.weeks().get(0).weekEndDate()).isEqualTo(endDate); - assertThat(response.weeks().get(0).days()).hasSize(7); - assertThat(response.weeks().get(0).days().get(1).date()) - .isEqualTo(LocalDate.of(2026, 3, 24)); - assertThat(response.weeks().get(0).days().get(1).exercises()) - .extracting( - PartyExerciseCalendarDTO.ExerciseCalendarItem::exerciseId, - PartyExerciseCalendarDTO.ExerciseCalendarItem::isBookmarked, - PartyExerciseCalendarDTO.ExerciseCalendarItem::buildingName, - PartyExerciseCalendarDTO.ExerciseCalendarItem::currentParticipants, - PartyExerciseCalendarDTO.ExerciseCalendarItem::maxCapacity, - PartyExerciseCalendarDTO.ExerciseCalendarItem::isParticipating) - .containsExactly(tuple(exercise.getId(), true, "테스트 체육관", 2, 10, true)); - } - - @Test - @DisplayName("기간 내 운동이 없으면 빈 캘린더를 반환한다") - void 기간_내_운동이_없으면_빈_캘린더를_반환한다() { - // given - given(partyRepository.findByIdWithLevels(party.getId())) - .willReturn(Optional.of(party)); - given(memberRepository.findById(outsiderMember.getId())) - .willReturn(Optional.of(outsiderMember)); - given(memberPartyRepository.existsByPartyAndMember(party, outsiderMember)) - .willReturn(false); - given(exerciseRepository.findByPartyIdAndDateRange(party.getId(), startDate, endDate)) - .willReturn(List.of()); - - // when - PartyExerciseCalendarDTO.Response response = exerciseQueryService.getPartyExerciseCalendar( - party.getId(), outsiderMember.getId(), startDate, endDate); - - // then - assertThat(response.startDate()).isEqualTo(startDate); - assertThat(response.endDate()).isEqualTo(endDate); - assertThat(response.isMember()).isFalse(); - assertThat(response.partyName()).isEqualTo(party.getPartyName()); - assertThat(response.weeks()).isEmpty(); - } - - @Test - @DisplayName("시작일과_종료일이_없으면_기본_기간이_적용된다") - void 시작일과_종료일이_없으면_기본_기간이_적용된다() { - // given - LocalDate expectedStart = ExerciseCalendarTestHelper.expectedDefaultStartDate(); - LocalDate expectedEnd = ExerciseCalendarTestHelper.expectedDefaultEndDate(); - - given(partyRepository.findByIdWithLevels(party.getId())) - .willReturn(Optional.of(party)); - given(memberRepository.findById(partyMember.getId())) - .willReturn(Optional.of(partyMember)); - given(memberPartyRepository.existsByPartyAndMember(party, partyMember)) - .willReturn(true); - given(exerciseRepository.findByPartyIdAndDateRange(party.getId(), expectedStart, expectedEnd)) - .willReturn(List.of()); - - // when - PartyExerciseCalendarDTO.Response response = exerciseQueryService.getPartyExerciseCalendar( - party.getId(), partyMember.getId(), null, null); - - // then - assertThat(response.startDate()).isEqualTo(expectedStart); - assertThat(response.endDate()).isEqualTo(expectedEnd); - assertThat(response.isMember()).isTrue(); - assertThat(response.weeks()).isEmpty(); - } - } - - @Nested - @DisplayName("실패 케이스") - class Failure { - - @Test - @DisplayName("시작일과 종료일이 함께 오지 않으면 예외를 던진다") - void 시작일과_종료일이_함께_오지_않으면_예외를_던진다() { - // given - given(partyRepository.findByIdWithLevels(party.getId())) - .willReturn(Optional.of(party)); - given(memberRepository.findById(partyMember.getId())) - .willReturn(Optional.of(partyMember)); - - // when & then - assertThatThrownBy(() -> exerciseQueryService.getPartyExerciseCalendar( - party.getId(), partyMember.getId(), startDate, null)) - .isInstanceOf(ExerciseException.class) - .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.INCOMPLETE_DATE_RANGE); - } - - @Test - @DisplayName("삭제된 모임이면 예외를 던진다") - void 삭제된_모임이면_예외를_던진다() { - // given - ReflectionTestUtils.setField(party, "status", PartyStatus.INACTIVE); - - given(partyRepository.findByIdWithLevels(party.getId())) - .willReturn(Optional.of(party)); - given(memberRepository.findById(partyMember.getId())) - .willReturn(Optional.of(partyMember)); - - // when & then - assertThatThrownBy(() -> exerciseQueryService.getPartyExerciseCalendar( - party.getId(), partyMember.getId(), startDate, endDate)) - .isInstanceOf(PartyException.class) - .hasFieldOrPropertyWithValue("code", PartyErrorCode.PARTY_IS_DELETED); - } - } - } - - @Nested - @DisplayName("getMyExerciseCalendar") - class GetMyExerciseCalendar { - - private Member calendarMember; - private LocalDate startDate; - private LocalDate endDate; - private Exercise myExercise; - - @BeforeEach - void setUp() { - calendarMember = MemberFixture.createMember("캘린더멤버", Gender.FEMALE, Level.B, 4001L); - ReflectionTestUtils.setField(calendarMember, "id", 4L); - - startDate = LocalDate.of(2026, 3, 23); - endDate = LocalDate.of(2026, 3, 29); - - myExercise = ExerciseFixture.createExerciseWithAddr(party, LocalDate.of(2026, 3, 25)); - ReflectionTestUtils.setField(myExercise, "id", 200L); - } - - @Nested - @DisplayName("성공 케이스") - class Success { - - @Test - @DisplayName("내 운동 캘린더를 주차별_일자별로 반환한다") - void 내_운동_캘린더를_주차별_일자별로_반환한다() { - // given - given(memberRepository.findById(calendarMember.getId())) - .willReturn(Optional.of(calendarMember)); - given(exerciseRepository.findByMemberIdAndDateRange(calendarMember.getId(), startDate, endDate)) - .willReturn(List.of(myExercise)); - - // when - MyExerciseCalendarDTO.Response response = exerciseQueryService.getMyExerciseCalendar( - calendarMember.getId(), startDate, endDate); - - // then - assertThat(response.startDate()).isEqualTo(startDate); - assertThat(response.endDate()).isEqualTo(endDate); - assertThat(response.weeks()).hasSize(1); - assertThat(response.weeks().get(0).weekStartDate()).isEqualTo(startDate); - assertThat(response.weeks().get(0).weekEndDate()).isEqualTo(endDate); - assertThat(response.weeks().get(0).days()).hasSize(7); - assertThat(response.weeks().get(0).days().get(2).date()) - .isEqualTo(LocalDate.of(2026, 3, 25)); - assertThat(response.weeks().get(0).days().get(2).exercises()) - .extracting( - MyExerciseCalendarDTO.ExerciseCalendarItem::exerciseId, - MyExerciseCalendarDTO.ExerciseCalendarItem::partyId, - MyExerciseCalendarDTO.ExerciseCalendarItem::partyName, - MyExerciseCalendarDTO.ExerciseCalendarItem::buildingName, - MyExerciseCalendarDTO.ExerciseCalendarItem::startTime, - MyExerciseCalendarDTO.ExerciseCalendarItem::endTime, - MyExerciseCalendarDTO.ExerciseCalendarItem::profileImageUrl) - .containsExactly(tuple(200L, 10L, "테스트 모임", "테스트 체육관", LocalTime.of(10, 0), null, null)); - } - - @Test - @DisplayName("기간 내 참여 운동이 없으면 빈 캘린더를 반환한다") - void 기간_내_참여_운동이_없으면_빈_캘린더를_반환한다() { - // given - given(memberRepository.findById(calendarMember.getId())) - .willReturn(Optional.of(calendarMember)); - given(exerciseRepository.findByMemberIdAndDateRange(calendarMember.getId(), startDate, endDate)) - .willReturn(List.of()); - - // when - MyExerciseCalendarDTO.Response response = exerciseQueryService.getMyExerciseCalendar( - calendarMember.getId(), startDate, endDate); - - // then - assertThat(response.startDate()).isEqualTo(startDate); - assertThat(response.endDate()).isEqualTo(endDate); - assertThat(response.weeks()).isEmpty(); - } - - @Test - @DisplayName("시작일과_종료일이_없으면_기본_기간이_적용된다") - void 시작일과_종료일이_없으면_기본_기간이_적용된다() { - // given - LocalDate expectedStart = ExerciseCalendarTestHelper.expectedDefaultStartDate(); - LocalDate expectedEnd = ExerciseCalendarTestHelper.expectedDefaultEndDate(); - - given(memberRepository.findById(calendarMember.getId())) - .willReturn(Optional.of(calendarMember)); - given(exerciseRepository.findByMemberIdAndDateRange(calendarMember.getId(), expectedStart, expectedEnd)) - .willReturn(List.of()); - - // when - MyExerciseCalendarDTO.Response response = exerciseQueryService.getMyExerciseCalendar( - calendarMember.getId(), null, null); - - // then - assertThat(response.startDate()).isEqualTo(expectedStart); - assertThat(response.endDate()).isEqualTo(expectedEnd); - assertThat(response.weeks()).isEmpty(); - } - } - - @Nested - @DisplayName("실패 케이스") - class Failure { - - @Test - @DisplayName("존재하지_않는_멤버면_예외를_던진다") - void 존재하지_않는_멤버면_예외를_던진다() { - // given - given(memberRepository.findById(999L)) - .willReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> exerciseQueryService.getMyExerciseCalendar(999L, startDate, endDate)) - .isInstanceOf(ExerciseException.class) - .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.MEMBER_NOT_FOUND); - } - - @Test - @DisplayName("시작일과 종료일이 함께 오지 않으면 예외를 던진다") - void 시작일과_종료일이_함께_오지_않으면_예외를_던진다() { - // given - given(memberRepository.findById(calendarMember.getId())) - .willReturn(Optional.of(calendarMember)); - - // when & then - assertThatThrownBy(() -> exerciseQueryService.getMyExerciseCalendar( - calendarMember.getId(), startDate, null)) - .isInstanceOf(ExerciseException.class) - .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.INCOMPLETE_DATE_RANGE); - } - - @Test - @DisplayName("시작일이 종료일과 같거나 늦으면 예외를 던진다") - void 시작일이_종료일과_같거나_늦으면_예외를_던진다() { - // given - given(memberRepository.findById(calendarMember.getId())) - .willReturn(Optional.of(calendarMember)); - - // when & then - assertThatThrownBy(() -> exerciseQueryService.getMyExerciseCalendar( - calendarMember.getId(), endDate, startDate)) - .isInstanceOf(ExerciseException.class) - .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.INVALID_DATE_RANGE); - } - } - } - - @Nested - @DisplayName("getMyPartyExercise") - class GetMyPartyExercise { - - private Member partyMember; - private Exercise firstUpcomingExercise; - private Exercise secondUpcomingExercise; - - @BeforeEach - void setUp() { - partyMember = MemberFixture.createMember("내모임멤버", Gender.MALE, Level.B, 5001L); - ReflectionTestUtils.setField(partyMember, "id", 5L); - - firstUpcomingExercise = ExerciseFixture.createExerciseWithAddr(party, LocalDate.of(2026, 4, 1)); - ReflectionTestUtils.setField(firstUpcomingExercise, "id", 301L); - - secondUpcomingExercise = ExerciseFixture.createExerciseWithAddr(party, LocalDate.of(2026, 4, 2)); - ReflectionTestUtils.setField(secondUpcomingExercise, "id", 302L); - } - - @Nested - @DisplayName("성공 케이스") - class Success { - - @Test - @DisplayName("내 모임의 예정된 운동 목록을 반환한다") - void 내_모임의_예정된_운동_목록을_반환한다() { - // given - given(memberRepository.findById(partyMember.getId())) - .willReturn(Optional.of(partyMember)); - given(memberPartyRepository.findPartyIdsByMemberId(partyMember.getId())) - .willReturn(List.of(party.getId())); - given(exerciseRepository.findRecentExercisesByPartyIds(eq(List.of(party.getId())), argThat( - (org.springframework.data.domain.Pageable pageable) -> pageable.getPageNumber() == 0 && pageable.getPageSize() == 6))) - .willReturn(List.of(firstUpcomingExercise, secondUpcomingExercise)); - - // when - MyPartyExerciseDTO.Response response = exerciseQueryService.getMyPartyExercise(partyMember.getId()); - - // then - assertThat(response.totalExercises()).isEqualTo(2); - assertThat(response.exercises()) - .extracting( - MyPartyExerciseDTO.Exercises::exerciseId, - MyPartyExerciseDTO.Exercises::partyId, - MyPartyExerciseDTO.Exercises::partyName, - MyPartyExerciseDTO.Exercises::buildingName, - MyPartyExerciseDTO.Exercises::date, - MyPartyExerciseDTO.Exercises::dayOfWeek, - MyPartyExerciseDTO.Exercises::startTime, - MyPartyExerciseDTO.Exercises::profileImageUrl) - .containsExactly( - tuple(301L, 10L, "테스트 모임", "테스트 체육관", LocalDate.of(2026, 4, 1), "WEDNESDAY", LocalTime.of(10, 0), null), - tuple(302L, 10L, "테스트 모임", "테스트 체육관", LocalDate.of(2026, 4, 2), "THURSDAY", LocalTime.of(10, 0), null) - ); - } - - @Test - @DisplayName("속한 모임이 없으면 빈 응답을 반환한다") - void 속한_모임이_없으면_빈_응답을_반환한다() { - // given - given(memberRepository.findById(partyMember.getId())) - .willReturn(Optional.of(partyMember)); - given(memberPartyRepository.findPartyIdsByMemberId(partyMember.getId())) - .willReturn(List.of()); - - // when - MyPartyExerciseDTO.Response response = exerciseQueryService.getMyPartyExercise(partyMember.getId()); - - // then - assertThat(response.totalExercises()).isZero(); - assertThat(response.exercises()).isEmpty(); - verify(exerciseRepository, never()).findRecentExercisesByPartyIds(any(), any()); - } - } - - @Nested - @DisplayName("실패 케이스") - class Failure { - - @Test - @DisplayName("존재하지_않는_멤버면_예외를_던진다") - void 존재하지_않는_멤버면_예외를_던진다() { - // given - given(memberRepository.findById(999L)) - .willReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> exerciseQueryService.getMyPartyExercise(999L)) - .isInstanceOf(ExerciseException.class) - .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.MEMBER_NOT_FOUND); - } - } - } - - @Nested - @DisplayName("getMyPartyExerciseCalendar") - class GetMyPartyExerciseCalendar { - - private Member calendarMember; - private Exercise calendarExercise; - private LocalDate startDate; - private LocalDate endDate; - - @BeforeEach - void setUp() { - calendarMember = MemberFixture.createMember("내모임캘린더멤버", Gender.FEMALE, Level.B, 6001L); - ReflectionTestUtils.setField(calendarMember, "id", 6L); - - startDate = LocalDate.of(2026, 3, 23); - endDate = LocalDate.of(2026, 3, 29); - - calendarExercise = ExerciseFixture.createExerciseWithAddr(party, LocalDate.of(2026, 3, 25)); - ReflectionTestUtils.setField(calendarExercise, "id", 400L); - } - - @Nested - @DisplayName("성공 케이스") - class Success { - - @Test - @DisplayName("내 모임 운동 캘린더를 주차별_일자별로 반환한다") - void 내_모임_운동_캘린더를_주차별_일자별로_반환한다() { - // given - given(memberRepository.findById(calendarMember.getId())) - .willReturn(Optional.of(calendarMember)); - given(memberPartyRepository.findPartyIdsByMemberId(calendarMember.getId())) - .willReturn(List.of(party.getId())); - given(exerciseRepository.findByPartyIdsAndDateRange(List.of(party.getId()), startDate, endDate)) - .willReturn(List.of(calendarExercise)); - given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( - calendarMember.getId(), List.of(calendarExercise.getId()))) - .willReturn(List.of()); - given(exerciseRepository.findExerciseParticipantCountsByExerciseIds( - List.of(calendarExercise.getId()), startDate, endDate)) - .willReturn(Collections.singletonList(new Object[]{calendarExercise.getId(), 3})); - - // when - MyPartyExerciseCalendarDTO.Response response = exerciseQueryService.getMyPartyExerciseCalendar( - calendarMember.getId(), MyPartyExerciseOrderType.LATEST, startDate, endDate); - - // then - assertThat(response.startDate()).isEqualTo(startDate); - assertThat(response.endDate()).isEqualTo(endDate); - assertThat(response.weeks()).hasSize(1); - assertThat(response.weeks().get(0).weekStartDate()).isEqualTo(startDate); - assertThat(response.weeks().get(0).weekEndDate()).isEqualTo(endDate); - assertThat(response.weeks().get(0).days()).hasSize(7); - assertThat(response.weeks().get(0).days().get(2).date()) - .isEqualTo(LocalDate.of(2026, 3, 25)); - assertThat(response.weeks().get(0).days().get(2).exercises()) - .extracting( - MyPartyExerciseCalendarDTO.ExerciseCalendarItem::exerciseId, - MyPartyExerciseCalendarDTO.ExerciseCalendarItem::partyId, - MyPartyExerciseCalendarDTO.ExerciseCalendarItem::partyName, - MyPartyExerciseCalendarDTO.ExerciseCalendarItem::buildingName, - MyPartyExerciseCalendarDTO.ExerciseCalendarItem::isBookmarked, - MyPartyExerciseCalendarDTO.ExerciseCalendarItem::nowCapacity) - .containsExactly(tuple(400L, 10L, "테스트 모임", "테스트 체육관", false, 3)); - } - - @Test - @DisplayName("북마크한 운동은 isBookmarked가 true로 반환된다") - void 북마크한_운동은_isBookmarked가_true로_반환된다() { - // given - given(memberRepository.findById(calendarMember.getId())) - .willReturn(Optional.of(calendarMember)); - given(memberPartyRepository.findPartyIdsByMemberId(calendarMember.getId())) - .willReturn(List.of(party.getId())); - given(exerciseRepository.findByPartyIdsAndDateRange(List.of(party.getId()), startDate, endDate)) - .willReturn(List.of(calendarExercise)); - given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( - calendarMember.getId(), List.of(calendarExercise.getId()))) - .willReturn(List.of(calendarExercise.getId())); - given(exerciseRepository.findExerciseParticipantCountsByExerciseIds( - List.of(calendarExercise.getId()), startDate, endDate)) - .willReturn(List.of()); - - // when - MyPartyExerciseCalendarDTO.Response response = exerciseQueryService.getMyPartyExerciseCalendar( - calendarMember.getId(), MyPartyExerciseOrderType.LATEST, startDate, endDate); - - // then - assertThat(response.weeks().get(0).days().get(2).exercises().get(0).isBookmarked()).isTrue(); - } - - @Test - @DisplayName("속한 모임이 없으면 빈 캘린더를 반환한다") - void 속한_모임이_없으면_빈_캘린더를_반환한다() { - // given - given(memberRepository.findById(calendarMember.getId())) - .willReturn(Optional.of(calendarMember)); - given(memberPartyRepository.findPartyIdsByMemberId(calendarMember.getId())) - .willReturn(List.of()); - - // when - MyPartyExerciseCalendarDTO.Response response = exerciseQueryService.getMyPartyExerciseCalendar( - calendarMember.getId(), MyPartyExerciseOrderType.LATEST, startDate, endDate); - - // then - assertThat(response.startDate()).isEqualTo(startDate); - assertThat(response.endDate()).isEqualTo(endDate); - assertThat(response.weeks()).isEmpty(); - verify(exerciseRepository, never()).findByPartyIdsAndDateRange(any(), any(), any()); - } - - @Test - @DisplayName("기간 내 내 모임 운동이 없으면 빈 캘린더를 반환한다") - void 기간_내_내_모임_운동이_없으면_빈_캘린더를_반환한다() { - // given - given(memberRepository.findById(calendarMember.getId())) - .willReturn(Optional.of(calendarMember)); - given(memberPartyRepository.findPartyIdsByMemberId(calendarMember.getId())) - .willReturn(List.of(party.getId())); - given(exerciseRepository.findByPartyIdsAndDateRange(List.of(party.getId()), startDate, endDate)) - .willReturn(List.of()); - - // when - MyPartyExerciseCalendarDTO.Response response = exerciseQueryService.getMyPartyExerciseCalendar( - calendarMember.getId(), MyPartyExerciseOrderType.LATEST, startDate, endDate); - - // then - assertThat(response.startDate()).isEqualTo(startDate); - assertThat(response.endDate()).isEqualTo(endDate); - assertThat(response.weeks()).isEmpty(); - } - - @Test - @DisplayName("시작일과_종료일이_없으면_기본_기간이_적용된다") - void 시작일과_종료일이_없으면_기본_기간이_적용된다() { - // given - LocalDate expectedStart = ExerciseCalendarTestHelper.expectedDefaultStartDate(); - LocalDate expectedEnd = ExerciseCalendarTestHelper.expectedDefaultEndDate(); - - given(memberRepository.findById(calendarMember.getId())) - .willReturn(Optional.of(calendarMember)); - given(memberPartyRepository.findPartyIdsByMemberId(calendarMember.getId())) - .willReturn(List.of(party.getId())); - given(exerciseRepository.findByPartyIdsAndDateRange(List.of(party.getId()), expectedStart, expectedEnd)) - .willReturn(List.of()); - - // when - MyPartyExerciseCalendarDTO.Response response = exerciseQueryService.getMyPartyExerciseCalendar( - calendarMember.getId(), MyPartyExerciseOrderType.LATEST, null, null); - - // then - assertThat(response.startDate()).isEqualTo(expectedStart); - assertThat(response.endDate()).isEqualTo(expectedEnd); - assertThat(response.weeks()).isEmpty(); - } - } - - @Nested - @DisplayName("실패 케이스") - class Failure { - - @Test - @DisplayName("존재하지_않는_멤버면_예외를_던진다") - void 존재하지_않는_멤버면_예외를_던진다() { - // given - given(memberRepository.findById(999L)) - .willReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> exerciseQueryService.getMyPartyExerciseCalendar( - 999L, MyPartyExerciseOrderType.LATEST, startDate, endDate)) - .isInstanceOf(ExerciseException.class) - .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.MEMBER_NOT_FOUND); - } - } - } - - @Nested - @DisplayName("getMyExercises") - class GetMyExercises { - - private Member myExerciseMember; - private Exercise completedExercise; - private Exercise upcomingExercise; - private Exercise futureLatestExercise; - private Pageable firstPage; - - @BeforeEach - void setUp() { - myExerciseMember = MemberFixture.createMember("내참여운동멤버", Gender.MALE, Level.B, 7001L, - LocalDate.of(2000, 1, 1)); - ReflectionTestUtils.setField(myExerciseMember, "id", 7L); - - party.addLevel(Gender.FEMALE, Level.B); - party.addLevel(Gender.MALE, Level.A); - - completedExercise = createMyExercise(701L, LocalDate.of(2024, 1, 5), - LocalTime.of(9, 0), LocalTime.of(11, 0), 18, false); - upcomingExercise = createMyExercise(702L, LocalDate.of(2099, 1, 3), - LocalTime.of(18, 0), null, 12, true); - futureLatestExercise = createMyExercise(703L, LocalDate.of(2099, 1, 10), - LocalTime.of(7, 30), LocalTime.of(9, 0), 20, true); - firstPage = PageRequest.of(0, 2); - } - - @Nested - @DisplayName("성공 케이스") - class Success { - - @Test - @DisplayName("ALL 최신순은 전체 운동 리포지토리를 날짜 내림차순으로 호출한다") - void ALL_최신순은_전체_운동_리포지토리를_날짜_내림차순으로_호출한다() { - // given - given(memberRepository.findById(myExerciseMember.getId())) - .willReturn(Optional.of(myExerciseMember)); - given(exerciseRepository.findMyExercisesWithPaging(eq(myExerciseMember.getId()), argThat( - pageable -> matchesSort(pageable, Sort.Direction.DESC, Sort.Direction.DESC)))) - .willReturn(emptySlice(firstPage)); - - // when - MyExerciseListDTO.Response response = exerciseQueryService.getMyExercises( - myExerciseMember.getId(), MyExerciseFilterType.ALL, MyExerciseOrderType.LATEST, firstPage); - - // then - assertThat(response.totalCount()).isZero(); - assertThat(response.hasNext()).isFalse(); - assertThat(response.exercises()).isEmpty(); - verify(exerciseRepository).findMyExercisesWithPaging(eq(myExerciseMember.getId()), any(Pageable.class)); - verify(exerciseRepository, never()).findMyUpcomingExercisesWithPaging(any(), any()); - verify(exerciseRepository, never()).findMyCompletedExercisesWithPaging(any(), any()); - } - - @Test - @DisplayName("UPCOMING 최신순은 예정 운동 리포지토리를 날짜 오름차순으로 호출한다") - void UPCOMING_최신순은_예정_운동_리포지토리를_날짜_오름차순으로_호출한다() { - // given - given(memberRepository.findById(myExerciseMember.getId())) - .willReturn(Optional.of(myExerciseMember)); - given(exerciseRepository.findMyUpcomingExercisesWithPaging(eq(myExerciseMember.getId()), argThat( - pageable -> matchesSort(pageable, Sort.Direction.ASC, Sort.Direction.ASC)))) - .willReturn(emptySlice(firstPage)); - - // when - exerciseQueryService.getMyExercises( - myExerciseMember.getId(), MyExerciseFilterType.UPCOMING, MyExerciseOrderType.LATEST, firstPage); - - // then - verify(exerciseRepository, never()).findMyExercisesWithPaging(any(), any()); - verify(exerciseRepository).findMyUpcomingExercisesWithPaging(eq(myExerciseMember.getId()), any(Pageable.class)); - verify(exerciseRepository, never()).findMyCompletedExercisesWithPaging(any(), any()); - } - - @Test - @DisplayName("COMPLETED 최신순은 완료 운동 리포지토리를 날짜 내림차순으로 호출한다") - void COMPLETED_최신순은_완료_운동_리포지토리를_날짜_내림차순으로_호출한다() { - // given - given(memberRepository.findById(myExerciseMember.getId())) - .willReturn(Optional.of(myExerciseMember)); - given(exerciseRepository.findMyCompletedExercisesWithPaging(eq(myExerciseMember.getId()), argThat( - pageable -> matchesSort(pageable, Sort.Direction.DESC, Sort.Direction.DESC)))) - .willReturn(emptySlice(firstPage)); - - // when - exerciseQueryService.getMyExercises( - myExerciseMember.getId(), MyExerciseFilterType.COMPLETED, MyExerciseOrderType.LATEST, firstPage); - - // then - verify(exerciseRepository, never()).findMyExercisesWithPaging(any(), any()); - verify(exerciseRepository, never()).findMyUpcomingExercisesWithPaging(any(), any()); - verify(exerciseRepository).findMyCompletedExercisesWithPaging(eq(myExerciseMember.getId()), any(Pageable.class)); - } - - @Test - @DisplayName("ALL 오래된순은 전체 운동 리포지토리를 날짜 오름차순으로 호출한다") - void ALL_오래된순은_전체_운동_리포지토리를_날짜_오름차순으로_호출한다() { - // given - given(memberRepository.findById(myExerciseMember.getId())) - .willReturn(Optional.of(myExerciseMember)); - given(exerciseRepository.findMyExercisesWithPaging(eq(myExerciseMember.getId()), argThat( - pageable -> matchesSort(pageable, Sort.Direction.ASC, Sort.Direction.ASC)))) - .willReturn(emptySlice(firstPage)); - - // when - exerciseQueryService.getMyExercises( - myExerciseMember.getId(), MyExerciseFilterType.ALL, MyExerciseOrderType.OLDEST, firstPage); - - // then - verify(exerciseRepository).findMyExercisesWithPaging(eq(myExerciseMember.getId()), any(Pageable.class)); - } - - @Test - @DisplayName("UPCOMING 오래된순은 예정 운동 리포지토리를 날짜 내림차순으로 호출한다") - void UPCOMING_오래된순은_예정_운동_리포지토리를_날짜_내림차순으로_호출한다() { - // given - given(memberRepository.findById(myExerciseMember.getId())) - .willReturn(Optional.of(myExerciseMember)); - given(exerciseRepository.findMyUpcomingExercisesWithPaging(eq(myExerciseMember.getId()), argThat( - pageable -> matchesSort(pageable, Sort.Direction.DESC, Sort.Direction.DESC)))) - .willReturn(emptySlice(firstPage)); - - // when - exerciseQueryService.getMyExercises( - myExerciseMember.getId(), MyExerciseFilterType.UPCOMING, MyExerciseOrderType.OLDEST, firstPage); - - // then - verify(exerciseRepository).findMyUpcomingExercisesWithPaging(eq(myExerciseMember.getId()), any(Pageable.class)); - } - - @Test - @DisplayName("COMPLETED 오래된순은 완료 운동 리포지토리를 날짜 오름차순으로 호출한다") - void COMPLETED_오래된순은_완료_운동_리포지토리를_날짜_오름차순으로_호출한다() { - // given - given(memberRepository.findById(myExerciseMember.getId())) - .willReturn(Optional.of(myExerciseMember)); - given(exerciseRepository.findMyCompletedExercisesWithPaging(eq(myExerciseMember.getId()), argThat( - pageable -> matchesSort(pageable, Sort.Direction.ASC, Sort.Direction.ASC)))) - .willReturn(emptySlice(firstPage)); - - // when - exerciseQueryService.getMyExercises( - myExerciseMember.getId(), MyExerciseFilterType.COMPLETED, MyExerciseOrderType.OLDEST, firstPage); - - // then - verify(exerciseRepository).findMyCompletedExercisesWithPaging(eq(myExerciseMember.getId()), any(Pageable.class)); - } - - @Test - @DisplayName("조회된 운동이 없으면 빈 응답을 반환한다") - void 조회된_운동이_없으면_빈_응답을_반환한다() { - // given - given(memberRepository.findById(myExerciseMember.getId())) - .willReturn(Optional.of(myExerciseMember)); - given(exerciseRepository.findMyExercisesWithPaging(eq(myExerciseMember.getId()), any(Pageable.class))) - .willReturn(emptySlice(firstPage)); - - // when - MyExerciseListDTO.Response response = exerciseQueryService.getMyExercises( - myExerciseMember.getId(), MyExerciseFilterType.ALL, MyExerciseOrderType.LATEST, firstPage); - - // then - assertThat(response.totalCount()).isZero(); - assertThat(response.hasNext()).isFalse(); - assertThat(response.exercises()).isEmpty(); - verify(exerciseRepository, never()).findExerciseParticipantCountsByExerciseIds(any()); - verify(exerciseBookmarkRepository, never()).findAllExerciseIdsByMemberIdAndExerciseIds(any(), any()); - } - - @Test - @DisplayName("조회 결과를 DTO 필드와 hasNext true로 매핑한다") - void 조회_결과를_DTO_필드와_hasNext_true로_매핑한다() { - // given - Slice exerciseSlice = sliceOf(List.of(futureLatestExercise, completedExercise), true, firstPage); - - given(memberRepository.findById(myExerciseMember.getId())) - .willReturn(Optional.of(myExerciseMember)); - given(exerciseRepository.findMyExercisesWithPaging(eq(myExerciseMember.getId()), any(Pageable.class))) - .willReturn(exerciseSlice); - given(exerciseRepository.findExerciseParticipantCountsByExerciseIds( - List.of(futureLatestExercise.getId(), completedExercise.getId()))) - .willReturn(List.of( - new Object[]{futureLatestExercise.getId(), 3}, - new Object[]{completedExercise.getId(), 1} - )); - given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( - myExerciseMember.getId(), List.of(futureLatestExercise.getId(), completedExercise.getId()))) - .willReturn(List.of(futureLatestExercise.getId())); - - // when - MyExerciseListDTO.Response response = exerciseQueryService.getMyExercises( - myExerciseMember.getId(), MyExerciseFilterType.ALL, MyExerciseOrderType.LATEST, firstPage); - - // then - assertThat(response.totalCount()).isEqualTo(2); - assertThat(response.hasNext()).isTrue(); - assertThat(response.exercises()) - .extracting( - MyExerciseListDTO.ExerciseItem::exerciseId, - MyExerciseListDTO.ExerciseItem::partyId, - MyExerciseListDTO.ExerciseItem::partyName, - MyExerciseListDTO.ExerciseItem::isBookmarked, - MyExerciseListDTO.ExerciseItem::date, - MyExerciseListDTO.ExerciseItem::dayOfWeek, - MyExerciseListDTO.ExerciseItem::buildingName, - MyExerciseListDTO.ExerciseItem::startTime, - MyExerciseListDTO.ExerciseItem::endTime, - MyExerciseListDTO.ExerciseItem::currentParticipants, - MyExerciseListDTO.ExerciseItem::maxCapacity, - MyExerciseListDTO.ExerciseItem::isCompleted, - MyExerciseListDTO.ExerciseItem::partyGuestInviteAccept - ) - .containsExactly( - tuple(703L, 10L, "테스트 모임", true, - LocalDate.of(2099, 1, 10), "SATURDAY", "테스트 체육관", - LocalTime.of(7, 30), LocalTime.of(9, 0), 3, 20, false, true), - tuple(701L, 10L, "테스트 모임", false, - LocalDate.of(2024, 1, 5), "FRIDAY", "테스트 체육관", - LocalTime.of(9, 0), LocalTime.of(11, 0), 1, 18, true, false) - ); - } - - @Test - @DisplayName("조회 결과를 hasNext false로 매핑한다") - void 조회_결과를_hasNext_false로_매핑한다() { - // given - Pageable secondPage = PageRequest.of(1, 1); - Slice exerciseSlice = sliceOf(List.of(upcomingExercise), false, secondPage); - - given(memberRepository.findById(myExerciseMember.getId())) - .willReturn(Optional.of(myExerciseMember)); - given(exerciseRepository.findMyExercisesWithPaging(eq(myExerciseMember.getId()), any(Pageable.class))) - .willReturn(exerciseSlice); - given(exerciseRepository.findExerciseParticipantCountsByExerciseIds(List.of(upcomingExercise.getId()))) - .willReturn(List.of()); - given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( - myExerciseMember.getId(), List.of(upcomingExercise.getId()))) - .willReturn(List.of()); - - // when - MyExerciseListDTO.Response response = exerciseQueryService.getMyExercises( - myExerciseMember.getId(), MyExerciseFilterType.ALL, MyExerciseOrderType.LATEST, secondPage); - - // then - assertThat(response.totalCount()).isEqualTo(1); - assertThat(response.hasNext()).isFalse(); - assertThat(response.exercises().get(0).exerciseId()).isEqualTo(upcomingExercise.getId()); - assertThat(response.exercises().get(0).isCompleted()).isFalse(); - } - } - - @Nested - @DisplayName("실패 케이스") - class Failure { - - @Test - @DisplayName("존재하지 않는 멤버면 예외를 던진다") - void 존재하지_않는_멤버면_예외를_던진다() { - // given - given(memberRepository.findById(999L)) - .willReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> exerciseQueryService.getMyExercises( - 999L, MyExerciseFilterType.ALL, MyExerciseOrderType.LATEST, firstPage)) - .isInstanceOf(ExerciseException.class) - .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.MEMBER_NOT_FOUND); - } - } - - private Exercise createMyExercise(long id, LocalDate date, LocalTime startTime, - LocalTime endTime, int maxCapacity, boolean partyGuestAccept) { - Exercise createdExercise = ExerciseFixture.createExerciseWithAddr(party, date, maxCapacity); - ReflectionTestUtils.setField(createdExercise, "id", id); - ReflectionTestUtils.setField(createdExercise, "startTime", startTime); - ReflectionTestUtils.setField(createdExercise, "endTime", endTime); - ReflectionTestUtils.setField(createdExercise, "partyGuestAccept", partyGuestAccept); - return createdExercise; - } - - private Slice emptySlice(Pageable pageable) { - return new SliceImpl<>(List.of(), pageable, false); - } - - private Slice sliceOf(List exercises, boolean hasNext, Pageable pageable) { - return new SliceImpl<>(exercises, pageable, hasNext); - } - - private boolean matchesSort(Pageable pageable, Sort.Direction dateDirection, Sort.Direction timeDirection) { - if (pageable.getPageNumber() != firstPage.getPageNumber() || pageable.getPageSize() != firstPage.getPageSize()) { - return false; - } - - List orders = pageable.getSort().stream().toList(); - return orders.size() == 2 - && orders.get(0).getProperty().equals("date") - && orders.get(0).getDirection() == dateDirection - && orders.get(1).getProperty().equals("startTime") - && orders.get(1).getDirection() == timeDirection; - } - } - - @Nested - @DisplayName("getBuildingExerciseDetails") - class GetBuildingExerciseDetails { - - private Member buildingMember; - private LocalDate targetDate; - private String buildingName; - private String streetAddr; - - @BeforeEach - void setUp() { - buildingMember = MemberFixture.createMember("건물상세멤버", Gender.FEMALE, Level.B, 8001L, - LocalDate.of(2000, 1, 1)); - ReflectionTestUtils.setField(buildingMember, "id", 8L); - - targetDate = LocalDate.of(2026, 5, 10); - buildingName = "콕플 타워"; - streetAddr = "서울특별시 강남구 테헤란로 10"; - } - - @Nested - @DisplayName("성공 케이스") - class Success { - - @Test - @DisplayName("해당 건물 운동이 없으면 메타데이터가 포함된 빈 응답을 반환한다") - void 해당_건물_운동이_없으면_메타데이터가_포함된_빈_응답을_반환한다() { - // given - given(memberRepository.findById(buildingMember.getId())) - .willReturn(Optional.of(buildingMember)); - given(exerciseRepository.findExercisesByBuildingAndDate(buildingName, streetAddr, targetDate)) - .willReturn(List.of()); - - // when - ExerciseBuildingDetailDTO.Response response = exerciseQueryService.getBuildingExerciseDetails( - buildingName, streetAddr, targetDate, buildingMember.getId()); - - // then - assertThat(response.date()).isEqualTo(targetDate); - assertThat(response.dayOfWeek()).isEqualTo("SUNDAY"); - assertThat(response.buildingName()).isEqualTo(buildingName); - assertThat(response.exercises()).isEmpty(); - verify(exerciseRepository).findExercisesByBuildingAndDate(buildingName, streetAddr, targetDate); - verify(exerciseBookmarkRepository, never()).findAllExerciseIdsByMemberIdAndExerciseIds(any(), any()); - } - - @Test - @DisplayName("운동 목록을 순서와 북마크 상태를 유지해 DTO로 반환한다") - void 운동_목록을_순서와_북마크_상태를_유지해_DTO로_반환한다() { - // given - Exercise morningExercise = createBuildingExercise(801L, LocalTime.of(9, 0), LocalTime.of(11, 0)); - Exercise eveningExercise = createBuildingExercise(802L, LocalTime.of(19, 0), LocalTime.of(21, 0)); - - given(memberRepository.findById(buildingMember.getId())) - .willReturn(Optional.of(buildingMember)); - given(exerciseRepository.findExercisesByBuildingAndDate(buildingName, streetAddr, targetDate)) - .willReturn(List.of(morningExercise, eveningExercise)); - given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( - buildingMember.getId(), List.of(morningExercise.getId(), eveningExercise.getId()))) - .willReturn(List.of(eveningExercise.getId())); - - // when - ExerciseBuildingDetailDTO.Response response = exerciseQueryService.getBuildingExerciseDetails( - buildingName, streetAddr, targetDate, buildingMember.getId()); - - // then - assertThat(response.date()).isEqualTo(targetDate); - assertThat(response.dayOfWeek()).isEqualTo("SUNDAY"); - assertThat(response.buildingName()).isEqualTo(buildingName); - assertThat(response.exercises()) - .extracting( - ExerciseBuildingDetailDTO.ExerciseItem::exerciseId, - ExerciseBuildingDetailDTO.ExerciseItem::partyId, - ExerciseBuildingDetailDTO.ExerciseItem::partyName, - ExerciseBuildingDetailDTO.ExerciseItem::profileImageUrl, - ExerciseBuildingDetailDTO.ExerciseItem::isBookmarked, - ExerciseBuildingDetailDTO.ExerciseItem::startTime, - ExerciseBuildingDetailDTO.ExerciseItem::endTime - ) - .containsExactly( - tuple(801L, 10L, "테스트 모임", null, false, LocalTime.of(9, 0), LocalTime.of(11, 0)), - tuple(802L, 10L, "테스트 모임", null, true, LocalTime.of(19, 0), LocalTime.of(21, 0)) - ); - verify(exerciseRepository).findExercisesByBuildingAndDate(buildingName, streetAddr, targetDate); - verify(exerciseBookmarkRepository).findAllExerciseIdsByMemberIdAndExerciseIds( - buildingMember.getId(), List.of(morningExercise.getId(), eveningExercise.getId())); - } - } - - @Nested - @DisplayName("실패 케이스") - class Failure { - - @Test - @DisplayName("존재하지 않는 멤버면 예외를 던진다") - void 존재하지_않는_멤버면_예외를_던진다() { - // given - given(memberRepository.findById(999L)) - .willReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> exerciseQueryService.getBuildingExerciseDetails( - buildingName, streetAddr, targetDate, 999L)) - .isInstanceOf(ExerciseException.class) - .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.MEMBER_NOT_FOUND); - } - } - - private Exercise createBuildingExercise(long id, LocalTime startTime, LocalTime endTime) { - Exercise buildingExercise = ExerciseFixture.createExerciseWithAddr(party, targetDate, 12); - ReflectionTestUtils.setField(buildingExercise, "id", id); - ReflectionTestUtils.setField(buildingExercise, "startTime", startTime); - ReflectionTestUtils.setField(buildingExercise, "endTime", endTime); - ReflectionTestUtils.setField(buildingExercise, "exerciseAddr", - ExerciseFixture.createExerciseAddr(buildingName, streetAddr)); - return buildingExercise; - } - } - - @Nested - @DisplayName("getExerciseMapCalendarSummary") - class GetExerciseMapCalendarSummary { - - private Member mapMember; - private Member memberWithoutMainAddr; - private MemberAddr mainAddr; - private Double radiusKm; - - @BeforeEach - void setUp() { - mapMember = MemberFixture.createMember("지도멤버", Gender.MALE, Level.B, 9001L, - LocalDate.of(2000, 1, 1)); - ReflectionTestUtils.setField(mapMember, "id", 9L); - - mainAddr = MemberAddr.builder() - .member(mapMember) - .addr1("서울특별시") - .addr2("강남구") - .addr3("역삼동") - .streetAddr("서울특별시 강남구 테헤란로 1") - .buildingName("대표주소") - .latitude(37.501) - .longitude(127.039) - .isMain(true) - .build(); - ReflectionTestUtils.setField(mapMember, "addresses", List.of(mainAddr)); - - memberWithoutMainAddr = MemberFixture.createMember("대표주소없음", Gender.FEMALE, Level.C, 9002L, - LocalDate.of(2001, 1, 1)); - ReflectionTestUtils.setField(memberWithoutMainAddr, "id", 10L); - MemberAddr subAddr = MemberAddr.builder() - .member(memberWithoutMainAddr) - .addr1("서울특별시") - .addr2("송파구") - .addr3("잠실동") - .streetAddr("서울특별시 송파구 올림픽로 1") - .buildingName("서브주소") - .latitude(37.514) - .longitude(127.102) - .isMain(false) - .build(); - ReflectionTestUtils.setField(memberWithoutMainAddr, "addresses", List.of(subAddr)); - - radiusKm = 3.9; - } - - @Nested - @DisplayName("성공 케이스") - class Success { - - @Test - @DisplayName("date가 null이면 현재 월 범위와 대표주소 좌표로 조회한다") - void date가_null이면_현재_월_범위와_대표주소_좌표로_조회한다() { - // given - YearMonth currentMonth = YearMonth.now(); - LocalDate monthStart = currentMonth.atDay(1); - LocalDate monthEnd = currentMonth.atEndOfMonth(); - - given(memberRepository.findMemberWithAddresses(mapMember.getId())) - .willReturn(Optional.of(mapMember)); - given(exerciseRepository.findExercisesByMonthAndRadius( - eq(monthStart), - eq(monthEnd), - eq(37.501), - eq(127.039), - eq(radiusKm))) - .willReturn(List.of()); - - // when - ExerciseMapBuildingsDTO.Response response = exerciseQueryService.getExerciseMapCalendarSummary( - createMapQuery(null, null, null, radiusKm), mapMember.getId()); - - // then - assertThat(response.year()).isEqualTo(currentMonth.getYear()); - assertThat(response.month()).isEqualTo(currentMonth.getMonthValue()); - assertThat(response.centerLatitude()).isEqualTo(mainAddr.getLatitude()); - assertThat(response.centerLongitude()).isEqualTo(mainAddr.getLongitude()); - assertThat(response.radiusKm()).isEqualTo(radiusKm); - assertThat(response.buildings()).isEmpty(); - } - - @Test - @DisplayName("명시 좌표가 있으면 대표주소 대신 해당 좌표와 소수 반경으로 조회한다") - void 명시_좌표가_있으면_대표주소_대신_해당_좌표와_소수_반경으로_조회한다() { - // given - LocalDate targetDate = LocalDate.of(2026, 4, 15); - LocalDate monthStart = LocalDate.of(2026, 4, 1); - LocalDate monthEnd = LocalDate.of(2026, 4, 30); - - given(memberRepository.findMemberWithAddresses(mapMember.getId())) - .willReturn(Optional.of(mapMember)); - given(exerciseRepository.findExercisesByMonthAndRadius( - eq(monthStart), - eq(monthEnd), - eq(37.55), - eq(127.11), - eq(radiusKm))) - .willReturn(List.of()); - - // when - ExerciseMapBuildingsDTO.Response response = exerciseQueryService.getExerciseMapCalendarSummary( - createMapQuery(targetDate, 37.55, 127.11, radiusKm), mapMember.getId()); - - // then - assertThat(response.year()).isEqualTo(2026); - assertThat(response.month()).isEqualTo(4); - assertThat(response.centerLatitude()).isEqualTo(37.55); - assertThat(response.centerLongitude()).isEqualTo(127.11); - assertThat(response.radiusKm()).isEqualTo(radiusKm); - assertThat(response.buildings()).isEmpty(); - } - - @Test - @DisplayName("운동을 날짜별과 건물별로 그룹화해 응답을 만든다") - void 운동을_날짜별과_건물별로_그룹화해_응답을_만든다() { - // given - LocalDate targetDate = LocalDate.of(2026, 4, 15); - Exercise dayOneMorning = createMapExercise(901L, LocalDate.of(2026, 4, 3), - "A빌딩", "서울특별시 강남구 테헤란로 10", 37.501, 127.041, LocalTime.of(9, 0)); - Exercise dayOneEveningSameBuilding = createMapExercise(902L, LocalDate.of(2026, 4, 3), - "A빌딩", "서울특별시 강남구 테헤란로 10", 37.501, 127.041, LocalTime.of(19, 0)); - Exercise dayOneOtherBuilding = createMapExercise(903L, LocalDate.of(2026, 4, 3), - "B빌딩", "서울특별시 강남구 테헤란로 20", 37.502, 127.042, LocalTime.of(13, 0)); - Exercise dayTwoBuilding = createMapExercise(904L, LocalDate.of(2026, 4, 4), - "A빌딩", "서울특별시 강남구 테헤란로 10", 37.501, 127.041, LocalTime.of(10, 0)); - - given(memberRepository.findMemberWithAddresses(mapMember.getId())) - .willReturn(Optional.of(mapMember)); - given(exerciseRepository.findExercisesByMonthAndRadius( - any(), any(), any(), any(), any())) - .willReturn(List.of(dayOneMorning, dayOneEveningSameBuilding, dayOneOtherBuilding, dayTwoBuilding)); - - // when - ExerciseMapBuildingsDTO.Response response = exerciseQueryService.getExerciseMapCalendarSummary( - createMapQuery(targetDate, null, null, radiusKm), mapMember.getId()); - - // then - assertThat(response.year()).isEqualTo(2026); - assertThat(response.month()).isEqualTo(4); - assertThat(response.centerLatitude()).isEqualTo(mainAddr.getLatitude()); - assertThat(response.centerLongitude()).isEqualTo(mainAddr.getLongitude()); - assertThat(response.radiusKm()).isEqualTo(radiusKm); - assertThat(response.buildings().keySet()) - .containsExactly(LocalDate.of(2026, 4, 3), LocalDate.of(2026, 4, 4)); - assertThat(response.buildings().get(LocalDate.of(2026, 4, 3))) - .extracting( - ExerciseMapBuildingsDTO.BuildingInfo::buildingName, - ExerciseMapBuildingsDTO.BuildingInfo::streetAddr, - ExerciseMapBuildingsDTO.BuildingInfo::latitude, - ExerciseMapBuildingsDTO.BuildingInfo::longitude - ) - .containsExactlyInAnyOrder( - tuple("A빌딩", "서울특별시 강남구 테헤란로 10", 37.501, 127.041), - tuple("B빌딩", "서울특별시 강남구 테헤란로 20", 37.502, 127.042) - ); - assertThat(response.buildings().get(LocalDate.of(2026, 4, 4))) - .extracting( - ExerciseMapBuildingsDTO.BuildingInfo::buildingName, - ExerciseMapBuildingsDTO.BuildingInfo::streetAddr - ) - .containsExactly(tuple("A빌딩", "서울특별시 강남구 테헤란로 10")); - } - } - - @Nested - @DisplayName("실패 케이스") - class Failure { - - @Test - @DisplayName("존재하지 않는 멤버면 예외를 던진다") - void 존재하지_않는_멤버면_예외를_던진다() { - // given - given(memberRepository.findMemberWithAddresses(999L)) - .willReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> exerciseQueryService.getExerciseMapCalendarSummary( - createMapQuery(LocalDate.of(2026, 4, 1), null, null, radiusKm), 999L)) - .isInstanceOf(ExerciseException.class) - .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.MEMBER_NOT_FOUND); - } - - @Test - @DisplayName("대표주소가 없으면 예외를 던진다") - void 대표주소가_없으면_예외를_던진다() { - // given - given(memberRepository.findMemberWithAddresses(memberWithoutMainAddr.getId())) - .willReturn(Optional.of(memberWithoutMainAddr)); - - // when & then - assertThatThrownBy(() -> exerciseQueryService.getExerciseMapCalendarSummary( - createMapQuery(LocalDate.of(2026, 4, 1), null, null, radiusKm), memberWithoutMainAddr.getId())) - .isInstanceOf(ExerciseException.class) - .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.MAIN_ADDRESS_NULL); - } - - @Test - @DisplayName("대표주소가 없으면 명시 좌표가 있어도 예외를 던진다") - void 대표주소가_없으면_명시_좌표가_있어도_예외를_던진다() { - // given - given(memberRepository.findMemberWithAddresses(memberWithoutMainAddr.getId())) - .willReturn(Optional.of(memberWithoutMainAddr)); - - // when & then - assertThatThrownBy(() -> exerciseQueryService.getExerciseMapCalendarSummary( - createMapQuery(LocalDate.of(2026, 4, 1), 37.5, 127.0, radiusKm), memberWithoutMainAddr.getId())) - .isInstanceOf(ExerciseException.class) - .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.MAIN_ADDRESS_NULL); - } - - } - - private ExerciseMapBuildingsDTO.Query createMapQuery( - LocalDate date, Double latitude, Double longitude, Double radiusKm) { - return ExerciseMapBuildingsDTO.Query.of(date, latitude, longitude, radiusKm); - } - - private Exercise createMapExercise(long id, LocalDate date, String buildingName, - String streetAddr, double latitude, double longitude, - LocalTime startTime) { - Exercise mapExercise = ExerciseFixture.createExerciseWithAddr(party, date, 12); - ReflectionTestUtils.setField(mapExercise, "id", id); - ReflectionTestUtils.setField(mapExercise, "startTime", startTime); - ReflectionTestUtils.setField(mapExercise, "exerciseAddr", - ExerciseFixture.createExerciseAddr(buildingName, streetAddr, latitude, longitude)); - return mapExercise; - } - } - - @Nested - @DisplayName("getRecommendedExerciseCalendar") - class GetRecommendedExerciseCalendar { - - private Member recommendationMember; - private Member memberWithoutMainAddr; - private MemberAddr mainAddr; - private Party filteredParty; - private LocalDate startDate; - private LocalDate endDate; - - @BeforeEach - void setUp() { - recommendationMember = MemberFixture.createMember("추천캘린더회원", Gender.MALE, Level.A, 11001L, - LocalDate.of(1995, 6, 15)); - ReflectionTestUtils.setField(recommendationMember, "id", 11L); - mainAddr = MemberAddrFixture.createMainAddr(recommendationMember); - ReflectionTestUtils.setField(recommendationMember, "addresses", List.of(mainAddr)); - - memberWithoutMainAddr = MemberFixture.createMember("주소없는추천회원", Gender.MALE, Level.A, 11002L, - LocalDate.of(1995, 6, 15)); - ReflectionTestUtils.setField(memberWithoutMainAddr, "id", 12L); - ReflectionTestUtils.setField(memberWithoutMainAddr, "addresses", List.of(MemberAddrFixture.createSubAddr(memberWithoutMainAddr))); - - party.addLevel(Gender.MALE, Level.A); - - filteredParty = PartyFixture.createParty("필터 모임", manager.getId(), - PartyFixture.createPartyAddr("서울특별시", "강남구")); - ReflectionTestUtils.setField(filteredParty, "id", 20L); - ReflectionTestUtils.setField(filteredParty, "partyType", ParticipationType.SINGLE); - ReflectionTestUtils.setField(filteredParty, "activityTime", ActivityTime.AFTERNOON); - filteredParty.addLevel(Gender.MALE, Level.B); - - startDate = LocalDate.of(2026, 3, 23); - endDate = LocalDate.of(2026, 4, 5); - } - - @Nested - @DisplayName("성공 케이스") - class Success { - - @Test - @DisplayName("콕플 추천 기본 기간은 기본 범위를 사용하고 거리순으로 정렬한다") - void 콕플_추천_기본_기간은_기본_범위를_사용하고_거리순으로_정렬한다() { - // given - LocalDate expectedStart = ExerciseCalendarTestHelper.expectedDefaultStartDate(); - LocalDate expectedEnd = ExerciseCalendarTestHelper.expectedDefaultEndDate(); - LocalDate targetDate = expectedStart.plusDays(9); - int weekIndex = ExerciseCalendarTestHelper.weekIndexFor(expectedStart, targetDate); - int dayIndex = ExerciseCalendarTestHelper.dayIndexFor(targetDate); - - Exercise nearExercise = createRecommendationExercise(party, 1001L, targetDate, - LocalTime.of(11, 0), LocalTime.of(13, 0), 37.5, 127.0, "가까운 체육관"); - Exercise farExercise = createRecommendationExercise(party, 1002L, targetDate, - LocalTime.of(9, 0), LocalTime.of(11, 0), 35.1, 129.1, "먼 체육관"); - - given(memberRepository.findMemberWithAddresses(recommendationMember.getId())) - .willReturn(Optional.of(recommendationMember)); - given(exerciseRepository.findCockpleRecommendedExercisesByDateRange( - recommendationMember.getId(), Gender.MALE, Level.A, 1995, expectedStart, expectedEnd)) - .willReturn(List.of(farExercise, nearExercise)); - given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( - recommendationMember.getId(), List.of(farExercise.getId(), nearExercise.getId()))) - .willReturn(List.of(nearExercise.getId())); - given(exerciseRepository.findExerciseParticipantCountsByExerciseIds( - List.of(farExercise.getId(), nearExercise.getId()))) - .willReturn(List.of()); - - // when - ExerciseRecommendationCalendarDTO.Response response = exerciseQueryService.getRecommendedExerciseCalendar( - recommendationMember.getId(), null, null, true, recommendationFilter(MyPartyExerciseOrderType.LATEST)); - - // then - assertThat(response.startDate()).isEqualTo(expectedStart); - assertThat(response.endDate()).isEqualTo(expectedEnd); - assertThat(response.weeks()).hasSize(5); - assertThat(response.weeks().get(weekIndex).days().get(dayIndex).exercises()) - .extracting( - ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::exerciseId, - ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::partyId, - ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::partyName, - ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::buildingName, - ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::startTime, - ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::endTime, - ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::isBookmarked - ) - .containsExactly( - tuple(nearExercise.getId(), party.getId(), "테스트 모임", "가까운 체육관", - LocalTime.of(11, 0), LocalTime.of(13, 0), true), - tuple(farExercise.getId(), party.getId(), "테스트 모임", "먼 체육관", - LocalTime.of(9, 0), LocalTime.of(11, 0), false) - ); - assertThat(response.weeks().get(weekIndex).days().get(dayIndex).exercises().get(0).distance()).isZero(); - assertThat(response.weeks().get(weekIndex).days().get(dayIndex).exercises().get(1).distance()).isGreaterThan(0.0); - verify(exerciseRepository).findCockpleRecommendedExercisesByDateRange( - recommendationMember.getId(), Gender.MALE, Level.A, 1995, expectedStart, expectedEnd); - verify(exerciseRepository, never()).findFilteredRecommendedExercisesForCalendar(any(), any(), any(), any(), any()); - } - - @Test - @DisplayName("필터 추천은 필터 리포지토리만 호출하고 인기순 정렬을 적용한다") - void 필터_추천은_필터_리포지토리만_호출하고_인기순_정렬을_적용한다() { - // given - Exercise popularExercise = createRecommendationExercise(filteredParty, 1101L, LocalDate.of(2026, 3, 25), - LocalTime.of(18, 0), LocalTime.of(20, 0), 37.52, 127.02, "인기 체육관"); - Exercise earlyExercise = createRecommendationExercise(filteredParty, 1102L, LocalDate.of(2026, 3, 25), - LocalTime.of(9, 0), LocalTime.of(11, 0), 37.53, 127.03, "이른 체육관"); - - ExerciseRecommendationCalendarDTO.FilterSortType filterSortType = ExerciseRecommendationCalendarDTO.FilterSortType.builder() - .addr1("서울특별시") - .addr2("강남구") - .levels(List.of(Level.B)) - .participationTypes(List.of(ParticipationType.SINGLE)) - .activityTimes(List.of(ActivityTime.AFTERNOON)) - .sortType(MyPartyExerciseOrderType.POPULARITY) - .build(); - - given(memberRepository.findMemberWithAddresses(recommendationMember.getId())) - .willReturn(Optional.of(recommendationMember)); - given(exerciseRepository.findFilteredRecommendedExercisesForCalendar( - recommendationMember.getId(), 1995, filterSortType, startDate, endDate)) - .willReturn(List.of(earlyExercise, popularExercise)); - given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( - recommendationMember.getId(), List.of(earlyExercise.getId(), popularExercise.getId()))) - .willReturn(List.of(popularExercise.getId())); - given(exerciseRepository.findExerciseParticipantCountsByExerciseIds( - List.of(earlyExercise.getId(), popularExercise.getId()))) - .willReturn(List.of( - new Object[]{popularExercise.getId(), 3}, - new Object[]{earlyExercise.getId(), 1} - )); - - // when - ExerciseRecommendationCalendarDTO.Response response = exerciseQueryService.getRecommendedExerciseCalendar( - recommendationMember.getId(), startDate, endDate, false, filterSortType); - - // then - assertThat(response.startDate()).isEqualTo(startDate); - assertThat(response.endDate()).isEqualTo(endDate); - assertThat(response.weeks().get(0).days().get(2).exercises()) - .extracting( - ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::exerciseId, - ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::partyId, - ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::partyName, - ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::buildingName, - ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::isBookmarked, - ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::distance - ) - .containsExactly( - tuple(popularExercise.getId(), filteredParty.getId(), "필터 모임", "인기 체육관", true, null), - tuple(earlyExercise.getId(), filteredParty.getId(), "필터 모임", "이른 체육관", false, null) - ); - verify(exerciseRepository, never()).findCockpleRecommendedExercisesByDateRange(any(), any(), any(), anyInt(), any(), any()); - verify(exerciseRepository).findFilteredRecommendedExercisesForCalendar( - recommendationMember.getId(), 1995, filterSortType, startDate, endDate); - } - - @Test - @DisplayName("추천 운동이 없으면 기간 메타데이터와 빈 일자별 캘린더를 반환한다") - void 추천_운동이_없으면_기간_메타데이터와_빈_일자별_캘린더를_반환한다() { - // given - given(memberRepository.findMemberWithAddresses(recommendationMember.getId())) - .willReturn(Optional.of(recommendationMember)); - given(exerciseRepository.findCockpleRecommendedExercisesByDateRange( - recommendationMember.getId(), Gender.MALE, Level.A, 1995, startDate, endDate)) - .willReturn(List.of()); - - // when - ExerciseRecommendationCalendarDTO.Response response = exerciseQueryService.getRecommendedExerciseCalendar( - recommendationMember.getId(), startDate, endDate, true, recommendationFilter(MyPartyExerciseOrderType.LATEST)); - - // then - assertThat(response.startDate()).isEqualTo(startDate); - assertThat(response.endDate()).isEqualTo(endDate); - assertThat(response.weeks()).hasSize(2); - assertThat(response.weeks().get(0).days()).hasSize(7); - assertThat(response.weeks().get(0).days().get(0).exercises()).isEmpty(); - } - - @Test - @DisplayName("startDate만 주어져도 기본 기간이 적용된다") - void startDate만_주어져도_기본_기간이_적용된다() { - // given - LocalDate expectedStart = ExerciseCalendarTestHelper.expectedDefaultStartDate(); - LocalDate expectedEnd = ExerciseCalendarTestHelper.expectedDefaultEndDate(); - - given(memberRepository.findMemberWithAddresses(recommendationMember.getId())) - .willReturn(Optional.of(recommendationMember)); - given(exerciseRepository.findCockpleRecommendedExercisesByDateRange( - recommendationMember.getId(), Gender.MALE, Level.A, 1995, expectedStart, expectedEnd)) - .willReturn(List.of()); - - // when - ExerciseRecommendationCalendarDTO.Response response = exerciseQueryService.getRecommendedExerciseCalendar( - recommendationMember.getId(), LocalDate.of(2026, 3, 25), null, true, - recommendationFilter(MyPartyExerciseOrderType.LATEST)); - - // then - assertThat(response.startDate()).isEqualTo(expectedStart); - assertThat(response.endDate()).isEqualTo(expectedEnd); - assertThat(response.weeks()).hasSize(5); - } - - @Test - @DisplayName("종료일이 시작일보다 이전이어도 빈 캘린더를 반환한다") - void 종료일이_시작일보다_이전이어도_빈_캘린더를_반환한다() { - // given - LocalDate reversedStart = LocalDate.of(2026, 4, 5); - LocalDate reversedEnd = LocalDate.of(2026, 3, 23); - - given(memberRepository.findMemberWithAddresses(recommendationMember.getId())) - .willReturn(Optional.of(recommendationMember)); - given(exerciseRepository.findCockpleRecommendedExercisesByDateRange( - recommendationMember.getId(), Gender.MALE, Level.A, 1995, reversedStart, reversedEnd)) - .willReturn(List.of()); - - // when - ExerciseRecommendationCalendarDTO.Response response = exerciseQueryService.getRecommendedExerciseCalendar( - recommendationMember.getId(), reversedStart, reversedEnd, true, - recommendationFilter(MyPartyExerciseOrderType.LATEST)); - - // then - assertThat(response.startDate()).isEqualTo(reversedStart); - assertThat(response.endDate()).isEqualTo(reversedEnd); - assertThat(response.weeks()).isEmpty(); - } - } - - @Nested - @DisplayName("실패 케이스") - class Failure { - - @Test - @DisplayName("존재하지 않는 멤버면 예외를 던진다") - void 존재하지_않는_멤버면_예외를_던진다() { - // given - given(memberRepository.findMemberWithAddresses(999L)) - .willReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> exerciseQueryService.getRecommendedExerciseCalendar( - 999L, startDate, endDate, true, recommendationFilter(MyPartyExerciseOrderType.LATEST))) - .isInstanceOf(ExerciseException.class) - .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.MEMBER_NOT_FOUND); - } - - @Test - @DisplayName("대표주소가 없으면 예외를 던진다") - void 대표주소가_없으면_예외를_던진다() { - // given - given(memberRepository.findMemberWithAddresses(memberWithoutMainAddr.getId())) - .willReturn(Optional.of(memberWithoutMainAddr)); - given(exerciseRepository.findCockpleRecommendedExercisesByDateRange( - memberWithoutMainAddr.getId(), Gender.MALE, Level.A, 1995, startDate, endDate)) - .willReturn(List.of()); - - // when & then - assertThatThrownBy(() -> exerciseQueryService.getRecommendedExerciseCalendar( - memberWithoutMainAddr.getId(), startDate, endDate, true, recommendationFilter(MyPartyExerciseOrderType.LATEST))) - .isInstanceOf(ExerciseException.class) - .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.MAIN_ADDRESS_NULL); - } - } - - private ExerciseRecommendationCalendarDTO.FilterSortType recommendationFilter(MyPartyExerciseOrderType sortType) { - return ExerciseRecommendationCalendarDTO.FilterSortType.builder() - .sortType(sortType) - .build(); - } - - private Exercise createRecommendationExercise(Party exerciseParty, long id, LocalDate date, - LocalTime startTime, LocalTime endTime, - double latitude, double longitude, String buildingName) { - Exercise recommendationExercise = ExerciseFixture.createRecommendableExercise( - exerciseParty, date, latitude, longitude, buildingName); - ReflectionTestUtils.setField(recommendationExercise, "id", id); - ReflectionTestUtils.setField(recommendationExercise, "startTime", startTime); - ReflectionTestUtils.setField(recommendationExercise, "endTime", endTime); - return recommendationExercise; - } - } -} diff --git a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseRecommendationQueryServiceTest.java b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseRecommendationQueryServiceTest.java new file mode 100644 index 000000000..895c2a90c --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseRecommendationQueryServiceTest.java @@ -0,0 +1,609 @@ +package umc.cockple.demo.domain.exercise.service; + +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.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import umc.cockple.demo.domain.bookmark.repository.ExerciseBookmarkRepository; +import umc.cockple.demo.domain.exercise.converter.ExerciseConverter; +import umc.cockple.demo.domain.exercise.domain.Exercise; +import umc.cockple.demo.domain.exercise.domain.ExerciseAddr; +import umc.cockple.demo.domain.exercise.dto.ExerciseRecommendationCalendarDTO; +import umc.cockple.demo.domain.exercise.dto.ExerciseRecommendationDTO; +import umc.cockple.demo.domain.exercise.enums.MyPartyExerciseOrderType; +import umc.cockple.demo.domain.exercise.exception.ExerciseErrorCode; +import umc.cockple.demo.domain.exercise.exception.ExerciseException; +import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; +import umc.cockple.demo.domain.bookmark.service.query.lookup.ExerciseBookmarkLookupService; +import umc.cockple.demo.domain.exercise.service.support.ExerciseDistanceCalculator; +import umc.cockple.demo.domain.exercise.service.support.reader.ExerciseReader; +import umc.cockple.demo.domain.member.service.support.MemberLookupService; +import umc.cockple.demo.domain.file.service.FileService; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.domain.MemberAddr; +import umc.cockple.demo.domain.member.exception.MemberErrorCode; +import umc.cockple.demo.domain.member.exception.MemberException; +import umc.cockple.demo.domain.member.repository.MemberExerciseRepository; +import umc.cockple.demo.domain.member.repository.MemberPartyRepository; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.domain.party.domain.Party; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.domain.exercise.service.query.ExerciseRecommendationQueryService; +import umc.cockple.demo.domain.exercise.service.query.lookup.ExerciseParticipantCountLookupService; +import umc.cockple.demo.domain.party.enums.ActivityTime; +import umc.cockple.demo.domain.party.enums.ParticipationType; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.support.ExerciseCalendarTestHelper; +import umc.cockple.demo.support.fixture.ExerciseFixture; +import umc.cockple.demo.support.fixture.MemberAddrFixture; +import umc.cockple.demo.support.fixture.MemberFixture; +import umc.cockple.demo.support.fixture.PartyFixture; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.groups.Tuple.tuple; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ExerciseRecommendationQueryService") +class ExerciseRecommendationQueryServiceTest { + + private ExerciseRecommendationQueryService exerciseRecommendationQueryService; + + @Mock private ExerciseRepository exerciseRepository; + @Mock private MemberRepository memberRepository; + @Mock private MemberPartyRepository memberPartyRepository; + @Mock private MemberExerciseRepository memberExerciseRepository; + @Mock private ExerciseBookmarkRepository exerciseBookmarkRepository; + @Mock private FileService fileService; + + private Member member; + private MemberAddr mainAddr; + private Party party; + private Exercise exercise; + + @BeforeEach + void setUp() { + ExerciseConverter exerciseConverter = new ExerciseConverter(fileService); + exerciseRecommendationQueryService = createExerciseRecommendationQueryService(exerciseConverter); + + member = MemberFixture.createMember("테스트회원", Gender.MALE, Level.A, 1001L, LocalDate.of(1995, 6, 15)); + ReflectionTestUtils.setField(member, "id", 1L); + + mainAddr = MemberAddrFixture.createMainAddr(member); + List addresses = new ArrayList<>(); + addresses.add(mainAddr); + ReflectionTestUtils.setField(member, "addresses", addresses); + + party = PartyFixture.createParty("테스트 모임", member.getId(), + PartyFixture.createPartyAddr("서울특별시", "강남구")); + ReflectionTestUtils.setField(party, "id", 10L); + + ExerciseAddr exerciseAddr = ExerciseFixture.createExerciseAddr(); + exercise = ExerciseFixture.createExercise(party, LocalDate.now().plusDays(3), + null, true, true); + ReflectionTestUtils.setField(exercise, "id", 100L); + ReflectionTestUtils.setField(exercise, "exerciseAddr", exerciseAddr); + } + + private ExerciseRecommendationQueryService createExerciseRecommendationQueryService(ExerciseConverter exerciseConverter) { + return new ExerciseRecommendationQueryService( + new ExerciseReader(exerciseRepository), + new ExerciseBookmarkLookupService(exerciseBookmarkRepository), + new ExerciseParticipantCountLookupService(exerciseRepository), + new ExerciseDistanceCalculator(), + new MemberLookupService(memberRepository), + exerciseConverter + ); + } + + @Nested + @DisplayName("getRecommendedExercises") + class GetRecommendedExercises { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("추천 운동이 존재하면 운동 목록과 총 개수를 반환한다") + void 추천_운동이_존재하면_목록과_총개수를_반환한다() { + // given + given(memberRepository.findMemberWithAddresses(member.getId())) + .willReturn(Optional.of(member)); + given(exerciseRepository.findExercisesByMemberIdAndLevelAndBirthYear( + eq(member.getId()), eq(Gender.MALE), eq(Level.A), eq(1995))) + .willReturn(List.of(exercise)); + given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( + eq(member.getId()), anyList())) + .willReturn(List.of()); + + // when + ExerciseRecommendationDTO.Response response = + exerciseRecommendationQueryService.getRecommendedExercises(member.getId()); + + // then + assertThat(response.totalExercises()).isEqualTo(1); + assertThat(response.exercises()).hasSize(1); + } + + @Test + @DisplayName("추천 운동의 필드가 올바르게 매핑된다") + void 추천_운동_필드가_올바르게_매핑된다() { + // given + given(memberRepository.findMemberWithAddresses(member.getId())) + .willReturn(Optional.of(member)); + given(exerciseRepository.findExercisesByMemberIdAndLevelAndBirthYear( + eq(member.getId()), eq(Gender.MALE), eq(Level.A), eq(1995))) + .willReturn(List.of(exercise)); + given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( + eq(member.getId()), anyList())) + .willReturn(List.of()); + + // when + ExerciseRecommendationDTO.Response response = + exerciseRecommendationQueryService.getRecommendedExercises(member.getId()); + + // then + ExerciseRecommendationDTO.ExerciseItem item = response.exercises().get(0); + assertThat(item.exerciseId()).isEqualTo(100L); + assertThat(item.partyId()).isEqualTo(10L); + assertThat(item.partyName()).isEqualTo("테스트 모임"); + assertThat(item.date()).isEqualTo(exercise.getDate()); + assertThat(item.dayOfWeek()).isEqualTo(exercise.getDate().getDayOfWeek().name()); + assertThat(item.buildingName()).isEqualTo("테스트 체육관"); + assertThat(item.isBookmarked()).isFalse(); + } + + @Test + @DisplayName("찜한 운동은 isBookmarked가 true로 반환된다") + void 찜한_운동은_isBookmarked가_true로_반환된다() { + // given + given(memberRepository.findMemberWithAddresses(member.getId())) + .willReturn(Optional.of(member)); + given(exerciseRepository.findExercisesByMemberIdAndLevelAndBirthYear( + eq(member.getId()), eq(Gender.MALE), eq(Level.A), eq(1995))) + .willReturn(List.of(exercise)); + given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( + eq(member.getId()), anyList())) + .willReturn(List.of(100L)); + + // when + ExerciseRecommendationDTO.Response response = + exerciseRecommendationQueryService.getRecommendedExercises(member.getId()); + + // then + assertThat(response.exercises().get(0).isBookmarked()).isTrue(); + } + + @Test + @DisplayName("추천 운동이 없으면 빈 목록과 totalExercises 0을 반환한다") + void 추천_운동이_없으면_빈_목록을_반환한다() { + // given + given(memberRepository.findMemberWithAddresses(member.getId())) + .willReturn(Optional.of(member)); + given(exerciseRepository.findExercisesByMemberIdAndLevelAndBirthYear( + eq(member.getId()), eq(Gender.MALE), eq(Level.A), eq(1995))) + .willReturn(Collections.emptyList()); + + // when + ExerciseRecommendationDTO.Response response = + exerciseRecommendationQueryService.getRecommendedExercises(member.getId()); + + // then + assertThat(response.totalExercises()).isEqualTo(0); + assertThat(response.exercises()).isEmpty(); + } + + @Test + @DisplayName("추천 운동이 10개를 초과하면 거리순으로 최대 10개만 반환된다") + void 추천_운동이_10개_초과하면_거리순으로_10개만_반환된다() { + // given - 같은 위치(거리 0)의 운동 12개 생성 + List candidates = new ArrayList<>(); + for (int i = 1; i <= 12; i++) { + Exercise ex = ExerciseFixture.createExercise(party, LocalDate.now().plusDays(i), + null, true, true); + ReflectionTestUtils.setField(ex, "id", (long) (100 + i)); + ReflectionTestUtils.setField(ex, "exerciseAddr", ExerciseFixture.createExerciseAddr()); + candidates.add(ex); + } + + given(memberRepository.findMemberWithAddresses(member.getId())) + .willReturn(Optional.of(member)); + given(exerciseRepository.findExercisesByMemberIdAndLevelAndBirthYear( + eq(member.getId()), eq(Gender.MALE), eq(Level.A), eq(1995))) + .willReturn(candidates); + given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( + eq(member.getId()), anyList())) + .willReturn(List.of()); + + // when + ExerciseRecommendationDTO.Response response = + exerciseRecommendationQueryService.getRecommendedExercises(member.getId()); + + // then + assertThat(response.totalExercises()).isEqualTo(10); + assertThat(response.exercises()).hasSize(10); + } + + @Test + @DisplayName("거리가 가까운 운동이 먼저 정렬된다") + void 거리가_가까운_운동이_먼저_정렬된다() { + // given - 거리가 다른 두 운동 (좌표 차이로 구분) + ExerciseAddr nearAddr = ExerciseAddr.builder() + .addr1("서울특별시").addr2("강남구") + .streetAddr("테헤란로 1").buildingName("가까운 체육관") + .latitude(37.5).longitude(127.0) // mainAddr과 동일 위치 -> 거리 0 + .build(); + ExerciseAddr farAddr = ExerciseAddr.builder() + .addr1("부산광역시").addr2("해운대구") + .streetAddr("해운대로 1").buildingName("먼 체육관") + .latitude(35.1).longitude(129.1) // 부산 -> 거리 멀다 + .build(); + + Exercise nearExercise = ExerciseFixture.createExercise(party, LocalDate.now().plusDays(5), + null, true, true); + ReflectionTestUtils.setField(nearExercise, "id", 101L); + ReflectionTestUtils.setField(nearExercise, "exerciseAddr", nearAddr); + + Exercise farExercise = ExerciseFixture.createExercise(party, LocalDate.now().plusDays(1), + null, true, true); + ReflectionTestUtils.setField(farExercise, "id", 102L); + ReflectionTestUtils.setField(farExercise, "exerciseAddr", farAddr); + + given(memberRepository.findMemberWithAddresses(member.getId())) + .willReturn(Optional.of(member)); + given(exerciseRepository.findExercisesByMemberIdAndLevelAndBirthYear( + eq(member.getId()), eq(Gender.MALE), eq(Level.A), eq(1995))) + .willReturn(List.of(farExercise, nearExercise)); // 먼 것을 먼저 넣어도 + given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( + eq(member.getId()), anyList())) + .willReturn(List.of()); + + // when + ExerciseRecommendationDTO.Response response = + exerciseRecommendationQueryService.getRecommendedExercises(member.getId()); + + // then - 가까운 운동이 먼저 + assertThat(response.exercises().get(0).exerciseId()).isEqualTo(101L); + assertThat(response.exercises().get(1).exerciseId()).isEqualTo(102L); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 회원이면 MEMBER_NOT_FOUND 예외가 발생한다") + void 존재하지_않는_회원이면_예외가_발생한다() { + // given + given(memberRepository.findMemberWithAddresses(999L)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> exerciseRecommendationQueryService.getRecommendedExercises(999L)) + .isInstanceOf(MemberException.class) + .satisfies(e -> assertThat(((MemberException) e).getCode()) + .isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND)); + } + + @Test + @DisplayName("대표 주소가 없으면 MAIN_ADDRESS_NULL 예외가 발생한다") + void 대표_주소가_없으면_예외가_발생한다() { + // given - addresses 비어 있는 member + Member memberWithoutAddr = MemberFixture.createMember("주소없는회원", Gender.MALE, Level.A, 2001L, LocalDate.of(1995, 1, 1)); + ReflectionTestUtils.setField(memberWithoutAddr, "id", 2L); + ReflectionTestUtils.setField(memberWithoutAddr, "addresses", new ArrayList<>()); + + given(memberRepository.findMemberWithAddresses(2L)) + .willReturn(Optional.of(memberWithoutAddr)); + + // when & then + assertThatThrownBy(() -> exerciseRecommendationQueryService.getRecommendedExercises(2L)) + .isInstanceOf(MemberException.class) + .satisfies(e -> assertThat(((MemberException) e).getCode()) + .isEqualTo(MemberErrorCode.MAIN_ADDRESS_NULL)); + } + } + } + @Nested + @DisplayName("getRecommendedExerciseCalendar") + class GetRecommendedExerciseCalendar { + + private Member recommendationMember; + private Member memberWithoutMainAddr; + private MemberAddr mainAddr; + private Party filteredParty; + private LocalDate startDate; + private LocalDate endDate; + + @BeforeEach + void setUp() { + recommendationMember = MemberFixture.createMember("추천캘린더회원", Gender.MALE, Level.A, 11001L, + LocalDate.of(1995, 6, 15)); + ReflectionTestUtils.setField(recommendationMember, "id", 11L); + mainAddr = MemberAddrFixture.createMainAddr(recommendationMember); + ReflectionTestUtils.setField(recommendationMember, "addresses", List.of(mainAddr)); + + memberWithoutMainAddr = MemberFixture.createMember("주소없는추천회원", Gender.MALE, Level.A, 11002L, + LocalDate.of(1995, 6, 15)); + ReflectionTestUtils.setField(memberWithoutMainAddr, "id", 12L); + ReflectionTestUtils.setField(memberWithoutMainAddr, "addresses", List.of(MemberAddrFixture.createSubAddr(memberWithoutMainAddr))); + + party.addLevel(Gender.MALE, Level.A); + + filteredParty = PartyFixture.createParty("필터 모임", recommendationMember.getId(), + PartyFixture.createPartyAddr("서울특별시", "강남구")); + ReflectionTestUtils.setField(filteredParty, "id", 20L); + ReflectionTestUtils.setField(filteredParty, "partyType", ParticipationType.SINGLE); + ReflectionTestUtils.setField(filteredParty, "activityTime", ActivityTime.AFTERNOON); + filteredParty.addLevel(Gender.MALE, Level.B); + + startDate = LocalDate.of(2026, 3, 23); + endDate = LocalDate.of(2026, 4, 5); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("콕플 추천 기본 기간은 기본 범위를 사용하고 거리순으로 정렬한다") + void 콕플_추천_기본_기간은_기본_범위를_사용하고_거리순으로_정렬한다() { + // given + LocalDate expectedStart = ExerciseCalendarTestHelper.expectedDefaultStartDate(); + LocalDate expectedEnd = ExerciseCalendarTestHelper.expectedDefaultEndDate(); + LocalDate targetDate = expectedStart.plusDays(9); + int weekIndex = ExerciseCalendarTestHelper.weekIndexFor(expectedStart, targetDate); + int dayIndex = ExerciseCalendarTestHelper.dayIndexFor(targetDate); + + Exercise nearExercise = createRecommendationExercise(party, 1001L, targetDate, + LocalTime.of(11, 0), LocalTime.of(13, 0), 37.5, 127.0, "가까운 체육관"); + Exercise farExercise = createRecommendationExercise(party, 1002L, targetDate, + LocalTime.of(9, 0), LocalTime.of(11, 0), 35.1, 129.1, "먼 체육관"); + + given(memberRepository.findMemberWithAddresses(recommendationMember.getId())) + .willReturn(Optional.of(recommendationMember)); + given(exerciseRepository.findCockpleRecommendedExercisesByDateRange( + recommendationMember.getId(), Gender.MALE, Level.A, 1995, expectedStart, expectedEnd)) + .willReturn(List.of(farExercise, nearExercise)); + given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( + recommendationMember.getId(), List.of(farExercise.getId(), nearExercise.getId()))) + .willReturn(List.of(nearExercise.getId())); + given(exerciseRepository.findExerciseParticipantCountsByExerciseIds( + List.of(farExercise.getId(), nearExercise.getId()))) + .willReturn(List.of()); + + // when + ExerciseRecommendationCalendarDTO.Response response = exerciseRecommendationQueryService.getRecommendedExerciseCalendar( + recommendationMember.getId(), null, null, true, recommendationFilter(MyPartyExerciseOrderType.LATEST)); + + // then + assertThat(response.startDate()).isEqualTo(expectedStart); + assertThat(response.endDate()).isEqualTo(expectedEnd); + assertThat(response.weeks()).hasSize(5); + assertThat(response.weeks().get(weekIndex).days().get(dayIndex).exercises()) + .extracting( + ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::exerciseId, + ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::partyId, + ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::partyName, + ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::buildingName, + ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::startTime, + ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::endTime, + ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::isBookmarked + ) + .containsExactly( + tuple(nearExercise.getId(), party.getId(), "테스트 모임", "가까운 체육관", + LocalTime.of(11, 0), LocalTime.of(13, 0), true), + tuple(farExercise.getId(), party.getId(), "테스트 모임", "먼 체육관", + LocalTime.of(9, 0), LocalTime.of(11, 0), false) + ); + assertThat(response.weeks().get(weekIndex).days().get(dayIndex).exercises().get(0).distance()).isZero(); + assertThat(response.weeks().get(weekIndex).days().get(dayIndex).exercises().get(1).distance()).isGreaterThan(0.0); + verify(exerciseRepository).findCockpleRecommendedExercisesByDateRange( + recommendationMember.getId(), Gender.MALE, Level.A, 1995, expectedStart, expectedEnd); + verify(exerciseRepository, never()).findFilteredRecommendedExercisesForCalendar(any(), any(), any(), any(), any()); + } + + @Test + @DisplayName("필터 추천은 필터 리포지토리만 호출하고 인기순 정렬을 적용한다") + void 필터_추천은_필터_리포지토리만_호출하고_인기순_정렬을_적용한다() { + // given + Exercise popularExercise = createRecommendationExercise(filteredParty, 1101L, LocalDate.of(2026, 3, 25), + LocalTime.of(18, 0), LocalTime.of(20, 0), 37.52, 127.02, "인기 체육관"); + Exercise earlyExercise = createRecommendationExercise(filteredParty, 1102L, LocalDate.of(2026, 3, 25), + LocalTime.of(9, 0), LocalTime.of(11, 0), 37.53, 127.03, "이른 체육관"); + + ExerciseRecommendationCalendarDTO.FilterSortType filterSortType = ExerciseRecommendationCalendarDTO.FilterSortType.builder() + .addr1("서울특별시") + .addr2("강남구") + .levels(List.of(Level.B)) + .participationTypes(List.of(ParticipationType.SINGLE)) + .activityTimes(List.of(ActivityTime.AFTERNOON)) + .sortType(MyPartyExerciseOrderType.POPULARITY) + .build(); + + given(memberRepository.findMemberWithAddresses(recommendationMember.getId())) + .willReturn(Optional.of(recommendationMember)); + given(exerciseRepository.findFilteredRecommendedExercisesForCalendar( + recommendationMember.getId(), 1995, filterSortType, startDate, endDate)) + .willReturn(List.of(earlyExercise, popularExercise)); + given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( + recommendationMember.getId(), List.of(earlyExercise.getId(), popularExercise.getId()))) + .willReturn(List.of(popularExercise.getId())); + given(exerciseRepository.findExerciseParticipantCountsByExerciseIds( + List.of(earlyExercise.getId(), popularExercise.getId()))) + .willReturn(List.of( + new Object[]{popularExercise.getId(), 3}, + new Object[]{earlyExercise.getId(), 1} + )); + + // when + ExerciseRecommendationCalendarDTO.Response response = exerciseRecommendationQueryService.getRecommendedExerciseCalendar( + recommendationMember.getId(), startDate, endDate, false, filterSortType); + + // then + assertThat(response.startDate()).isEqualTo(startDate); + assertThat(response.endDate()).isEqualTo(endDate); + assertThat(response.weeks().get(0).days().get(2).exercises()) + .extracting( + ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::exerciseId, + ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::partyId, + ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::partyName, + ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::buildingName, + ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::isBookmarked, + ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::distance + ) + .containsExactly( + tuple(popularExercise.getId(), filteredParty.getId(), "필터 모임", "인기 체육관", true, null), + tuple(earlyExercise.getId(), filteredParty.getId(), "필터 모임", "이른 체육관", false, null) + ); + verify(exerciseRepository, never()).findCockpleRecommendedExercisesByDateRange(any(), any(), any(), anyInt(), any(), any()); + verify(exerciseRepository).findFilteredRecommendedExercisesForCalendar( + recommendationMember.getId(), 1995, filterSortType, startDate, endDate); + } + + @Test + @DisplayName("추천 운동이 없으면 기간 메타데이터와 빈 일자별 캘린더를 반환한다") + void 추천_운동이_없으면_기간_메타데이터와_빈_일자별_캘린더를_반환한다() { + // given + given(memberRepository.findMemberWithAddresses(recommendationMember.getId())) + .willReturn(Optional.of(recommendationMember)); + given(exerciseRepository.findCockpleRecommendedExercisesByDateRange( + recommendationMember.getId(), Gender.MALE, Level.A, 1995, startDate, endDate)) + .willReturn(List.of()); + + // when + ExerciseRecommendationCalendarDTO.Response response = exerciseRecommendationQueryService.getRecommendedExerciseCalendar( + recommendationMember.getId(), startDate, endDate, true, recommendationFilter(MyPartyExerciseOrderType.LATEST)); + + // then + assertThat(response.startDate()).isEqualTo(startDate); + assertThat(response.endDate()).isEqualTo(endDate); + assertThat(response.weeks()).hasSize(2); + assertThat(response.weeks().get(0).days()).hasSize(7); + assertThat(response.weeks().get(0).days().get(0).exercises()).isEmpty(); + verify(exerciseRepository, never()).findExerciseParticipantCountsByExerciseIds(any()); + } + + @Test + @DisplayName("startDate만 주어져도 기본 기간이 적용된다") + void startDate만_주어져도_기본_기간이_적용된다() { + // given + LocalDate expectedStart = ExerciseCalendarTestHelper.expectedDefaultStartDate(); + LocalDate expectedEnd = ExerciseCalendarTestHelper.expectedDefaultEndDate(); + + given(memberRepository.findMemberWithAddresses(recommendationMember.getId())) + .willReturn(Optional.of(recommendationMember)); + given(exerciseRepository.findCockpleRecommendedExercisesByDateRange( + recommendationMember.getId(), Gender.MALE, Level.A, 1995, expectedStart, expectedEnd)) + .willReturn(List.of()); + + // when + ExerciseRecommendationCalendarDTO.Response response = exerciseRecommendationQueryService.getRecommendedExerciseCalendar( + recommendationMember.getId(), LocalDate.of(2026, 3, 25), null, true, + recommendationFilter(MyPartyExerciseOrderType.LATEST)); + + // then + assertThat(response.startDate()).isEqualTo(expectedStart); + assertThat(response.endDate()).isEqualTo(expectedEnd); + assertThat(response.weeks()).hasSize(5); + } + + @Test + @DisplayName("종료일이 시작일보다 이전이어도 빈 캘린더를 반환한다") + void 종료일이_시작일보다_이전이어도_빈_캘린더를_반환한다() { + // given + LocalDate reversedStart = LocalDate.of(2026, 4, 5); + LocalDate reversedEnd = LocalDate.of(2026, 3, 23); + + given(memberRepository.findMemberWithAddresses(recommendationMember.getId())) + .willReturn(Optional.of(recommendationMember)); + given(exerciseRepository.findCockpleRecommendedExercisesByDateRange( + recommendationMember.getId(), Gender.MALE, Level.A, 1995, reversedStart, reversedEnd)) + .willReturn(List.of()); + + // when + ExerciseRecommendationCalendarDTO.Response response = exerciseRecommendationQueryService.getRecommendedExerciseCalendar( + recommendationMember.getId(), reversedStart, reversedEnd, true, + recommendationFilter(MyPartyExerciseOrderType.LATEST)); + + // then + assertThat(response.startDate()).isEqualTo(reversedStart); + assertThat(response.endDate()).isEqualTo(reversedEnd); + assertThat(response.weeks()).isEmpty(); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 멤버면 예외를 던진다") + void 존재하지_않는_멤버면_예외를_던진다() { + // given + given(memberRepository.findMemberWithAddresses(999L)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> exerciseRecommendationQueryService.getRecommendedExerciseCalendar( + 999L, startDate, endDate, true, recommendationFilter(MyPartyExerciseOrderType.LATEST))) + .isInstanceOf(MemberException.class) + .hasFieldOrPropertyWithValue("code", MemberErrorCode.MEMBER_NOT_FOUND); + } + + @Test + @DisplayName("대표주소가 없으면 예외를 던진다") + void 대표주소가_없으면_예외를_던진다() { + // given + given(memberRepository.findMemberWithAddresses(memberWithoutMainAddr.getId())) + .willReturn(Optional.of(memberWithoutMainAddr)); + given(exerciseRepository.findCockpleRecommendedExercisesByDateRange( + memberWithoutMainAddr.getId(), Gender.MALE, Level.A, 1995, startDate, endDate)) + .willReturn(List.of()); + + // when & then + assertThatThrownBy(() -> exerciseRecommendationQueryService.getRecommendedExerciseCalendar( + memberWithoutMainAddr.getId(), startDate, endDate, true, recommendationFilter(MyPartyExerciseOrderType.LATEST))) + .isInstanceOf(MemberException.class) + .hasFieldOrPropertyWithValue("code", MemberErrorCode.MAIN_ADDRESS_NULL); + } + } + + private ExerciseRecommendationCalendarDTO.FilterSortType recommendationFilter(MyPartyExerciseOrderType sortType) { + return ExerciseRecommendationCalendarDTO.FilterSortType.builder() + .sortType(sortType) + .build(); + } + + private Exercise createRecommendationExercise(Party exerciseParty, long id, LocalDate date, + LocalTime startTime, LocalTime endTime, + double latitude, double longitude, String buildingName) { + Exercise recommendationExercise = ExerciseFixture.createRecommendableExercise( + exerciseParty, date, latitude, longitude, buildingName); + ReflectionTestUtils.setField(recommendationExercise, "id", id); + ReflectionTestUtils.setField(recommendationExercise, "startTime", startTime); + ReflectionTestUtils.setField(recommendationExercise, "endTime", endTime); + return recommendationExercise; + } + } +} diff --git a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseRecommendationServiceTest.java b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseRecommendationServiceTest.java deleted file mode 100644 index 4420a0d00..000000000 --- a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseRecommendationServiceTest.java +++ /dev/null @@ -1,304 +0,0 @@ -package umc.cockple.demo.domain.exercise.service; - -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.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.test.util.ReflectionTestUtils; -import umc.cockple.demo.domain.bookmark.repository.ExerciseBookmarkRepository; -import umc.cockple.demo.domain.exercise.converter.ExerciseConverter; -import umc.cockple.demo.domain.exercise.domain.Exercise; -import umc.cockple.demo.domain.exercise.domain.ExerciseAddr; -import umc.cockple.demo.domain.exercise.dto.ExerciseRecommendationDTO; -import umc.cockple.demo.domain.exercise.exception.ExerciseErrorCode; -import umc.cockple.demo.domain.exercise.exception.ExerciseException; -import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; -import umc.cockple.demo.domain.exercise.repository.GuestRepository; -import umc.cockple.demo.domain.file.service.FileService; -import umc.cockple.demo.domain.member.domain.Member; -import umc.cockple.demo.domain.member.domain.MemberAddr; -import umc.cockple.demo.domain.member.repository.MemberExerciseRepository; -import umc.cockple.demo.domain.member.repository.MemberPartyRepository; -import umc.cockple.demo.domain.member.repository.MemberRepository; -import umc.cockple.demo.domain.party.domain.Party; -import umc.cockple.demo.domain.party.repository.PartyRepository; -import umc.cockple.demo.global.enums.Gender; -import umc.cockple.demo.global.enums.Level; -import umc.cockple.demo.support.fixture.ExerciseFixture; -import umc.cockple.demo.support.fixture.MemberAddrFixture; -import umc.cockple.demo.support.fixture.MemberFixture; -import umc.cockple.demo.support.fixture.PartyFixture; - -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyList; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.BDDMockito.given; - -@ExtendWith(MockitoExtension.class) -@DisplayName("ExerciseQueryService - 사용자 추천 운동 조회") -class ExerciseRecommendationServiceTest { - - @InjectMocks - private ExerciseQueryService exerciseQueryService; - - @Mock private ExerciseRepository exerciseRepository; - @Mock private MemberRepository memberRepository; - @Mock private MemberPartyRepository memberPartyRepository; - @Mock private MemberExerciseRepository memberExerciseRepository; - @Mock private GuestRepository guestRepository; - @Mock private PartyRepository partyRepository; - @Mock private ExerciseBookmarkRepository exerciseBookmarkRepository; - @Mock private FileService fileService; - - private Member member; - private MemberAddr mainAddr; - private Party party; - private Exercise exercise; - - @BeforeEach - void setUp() { - ExerciseConverter exerciseConverter = new ExerciseConverter(fileService); - ReflectionTestUtils.setField(exerciseQueryService, "exerciseConverter", exerciseConverter); - - member = MemberFixture.createMember("테스트회원", Gender.MALE, Level.A, 1001L, LocalDate.of(1995, 6, 15)); - ReflectionTestUtils.setField(member, "id", 1L); - - mainAddr = MemberAddrFixture.createMainAddr(member); - List addresses = new ArrayList<>(); - addresses.add(mainAddr); - ReflectionTestUtils.setField(member, "addresses", addresses); - - party = PartyFixture.createParty("테스트 모임", member.getId(), - PartyFixture.createPartyAddr("서울특별시", "강남구")); - ReflectionTestUtils.setField(party, "id", 10L); - - ExerciseAddr exerciseAddr = ExerciseFixture.createExerciseAddr(); - exercise = ExerciseFixture.createExercise(party, LocalDate.now().plusDays(3), - null, true, true); - ReflectionTestUtils.setField(exercise, "id", 100L); - ReflectionTestUtils.setField(exercise, "exerciseAddr", exerciseAddr); - } - - @Nested - @DisplayName("getRecommendedExercises") - class GetRecommendedExercises { - - @Nested - @DisplayName("성공 케이스") - class Success { - - @Test - @DisplayName("추천 운동이 존재하면 운동 목록과 총 개수를 반환한다") - void 추천_운동이_존재하면_목록과_총개수를_반환한다() { - // given - given(memberRepository.findMemberWithAddresses(member.getId())) - .willReturn(Optional.of(member)); - given(exerciseRepository.findExercisesByMemberIdAndLevelAndBirthYear( - eq(member.getId()), eq(Gender.MALE), eq(Level.A), eq(1995))) - .willReturn(List.of(exercise)); - given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( - eq(member.getId()), anyList())) - .willReturn(List.of()); - - // when - ExerciseRecommendationDTO.Response response = - exerciseQueryService.getRecommendedExercises(member.getId()); - - // then - assertThat(response.totalExercises()).isEqualTo(1); - assertThat(response.exercises()).hasSize(1); - } - - @Test - @DisplayName("추천 운동의 필드가 올바르게 매핑된다") - void 추천_운동_필드가_올바르게_매핑된다() { - // given - given(memberRepository.findMemberWithAddresses(member.getId())) - .willReturn(Optional.of(member)); - given(exerciseRepository.findExercisesByMemberIdAndLevelAndBirthYear( - eq(member.getId()), eq(Gender.MALE), eq(Level.A), eq(1995))) - .willReturn(List.of(exercise)); - given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( - eq(member.getId()), anyList())) - .willReturn(List.of()); - - // when - ExerciseRecommendationDTO.Response response = - exerciseQueryService.getRecommendedExercises(member.getId()); - - // then - ExerciseRecommendationDTO.ExerciseItem item = response.exercises().get(0); - assertThat(item.exerciseId()).isEqualTo(100L); - assertThat(item.partyId()).isEqualTo(10L); - assertThat(item.partyName()).isEqualTo("테스트 모임"); - assertThat(item.date()).isEqualTo(exercise.getDate()); - assertThat(item.dayOfWeek()).isEqualTo(exercise.getDate().getDayOfWeek().name()); - assertThat(item.buildingName()).isEqualTo("테스트 체육관"); - assertThat(item.isBookmarked()).isFalse(); - } - - @Test - @DisplayName("찜한 운동은 isBookmarked가 true로 반환된다") - void 찜한_운동은_isBookmarked가_true로_반환된다() { - // given - given(memberRepository.findMemberWithAddresses(member.getId())) - .willReturn(Optional.of(member)); - given(exerciseRepository.findExercisesByMemberIdAndLevelAndBirthYear( - eq(member.getId()), eq(Gender.MALE), eq(Level.A), eq(1995))) - .willReturn(List.of(exercise)); - given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( - eq(member.getId()), anyList())) - .willReturn(List.of(100L)); - - // when - ExerciseRecommendationDTO.Response response = - exerciseQueryService.getRecommendedExercises(member.getId()); - - // then - assertThat(response.exercises().get(0).isBookmarked()).isTrue(); - } - - @Test - @DisplayName("추천 운동이 없으면 빈 목록과 totalExercises 0을 반환한다") - void 추천_운동이_없으면_빈_목록을_반환한다() { - // given - given(memberRepository.findMemberWithAddresses(member.getId())) - .willReturn(Optional.of(member)); - given(exerciseRepository.findExercisesByMemberIdAndLevelAndBirthYear( - eq(member.getId()), eq(Gender.MALE), eq(Level.A), eq(1995))) - .willReturn(Collections.emptyList()); - - // when - ExerciseRecommendationDTO.Response response = - exerciseQueryService.getRecommendedExercises(member.getId()); - - // then - assertThat(response.totalExercises()).isEqualTo(0); - assertThat(response.exercises()).isEmpty(); - } - - @Test - @DisplayName("추천 운동이 10개를 초과하면 거리순으로 최대 10개만 반환된다") - void 추천_운동이_10개_초과하면_거리순으로_10개만_반환된다() { - // given - 같은 위치(거리 0)의 운동 12개 생성 - List candidates = new ArrayList<>(); - for (int i = 1; i <= 12; i++) { - Exercise ex = ExerciseFixture.createExercise(party, LocalDate.now().plusDays(i), - null, true, true); - ReflectionTestUtils.setField(ex, "id", (long) (100 + i)); - ReflectionTestUtils.setField(ex, "exerciseAddr", ExerciseFixture.createExerciseAddr()); - candidates.add(ex); - } - - given(memberRepository.findMemberWithAddresses(member.getId())) - .willReturn(Optional.of(member)); - given(exerciseRepository.findExercisesByMemberIdAndLevelAndBirthYear( - eq(member.getId()), eq(Gender.MALE), eq(Level.A), eq(1995))) - .willReturn(candidates); - given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( - eq(member.getId()), anyList())) - .willReturn(List.of()); - - // when - ExerciseRecommendationDTO.Response response = - exerciseQueryService.getRecommendedExercises(member.getId()); - - // then - assertThat(response.totalExercises()).isEqualTo(10); - assertThat(response.exercises()).hasSize(10); - } - - @Test - @DisplayName("거리가 가까운 운동이 먼저 정렬된다") - void 거리가_가까운_운동이_먼저_정렬된다() { - // given - 거리가 다른 두 운동 (좌표 차이로 구분) - ExerciseAddr nearAddr = ExerciseAddr.builder() - .addr1("서울특별시").addr2("강남구") - .streetAddr("테헤란로 1").buildingName("가까운 체육관") - .latitude(37.5).longitude(127.0) // mainAddr과 동일 위치 -> 거리 0 - .build(); - ExerciseAddr farAddr = ExerciseAddr.builder() - .addr1("부산광역시").addr2("해운대구") - .streetAddr("해운대로 1").buildingName("먼 체육관") - .latitude(35.1).longitude(129.1) // 부산 -> 거리 멀다 - .build(); - - Exercise nearExercise = ExerciseFixture.createExercise(party, LocalDate.now().plusDays(5), - null, true, true); - ReflectionTestUtils.setField(nearExercise, "id", 101L); - ReflectionTestUtils.setField(nearExercise, "exerciseAddr", nearAddr); - - Exercise farExercise = ExerciseFixture.createExercise(party, LocalDate.now().plusDays(1), - null, true, true); - ReflectionTestUtils.setField(farExercise, "id", 102L); - ReflectionTestUtils.setField(farExercise, "exerciseAddr", farAddr); - - given(memberRepository.findMemberWithAddresses(member.getId())) - .willReturn(Optional.of(member)); - given(exerciseRepository.findExercisesByMemberIdAndLevelAndBirthYear( - eq(member.getId()), eq(Gender.MALE), eq(Level.A), eq(1995))) - .willReturn(List.of(farExercise, nearExercise)); // 먼 것을 먼저 넣어도 - given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( - eq(member.getId()), anyList())) - .willReturn(List.of()); - - // when - ExerciseRecommendationDTO.Response response = - exerciseQueryService.getRecommendedExercises(member.getId()); - - // then - 가까운 운동이 먼저 - assertThat(response.exercises().get(0).exerciseId()).isEqualTo(101L); - assertThat(response.exercises().get(1).exerciseId()).isEqualTo(102L); - } - } - - @Nested - @DisplayName("실패 케이스") - class Failure { - - @Test - @DisplayName("존재하지 않는 회원이면 MEMBER_NOT_FOUND 예외가 발생한다") - void 존재하지_않는_회원이면_예외가_발생한다() { - // given - given(memberRepository.findMemberWithAddresses(999L)) - .willReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> exerciseQueryService.getRecommendedExercises(999L)) - .isInstanceOf(ExerciseException.class) - .satisfies(e -> assertThat(((ExerciseException) e).getCode()) - .isEqualTo(ExerciseErrorCode.MEMBER_NOT_FOUND)); - } - - @Test - @DisplayName("대표 주소가 없으면 MAIN_ADDRESS_NULL 예외가 발생한다") - void 대표_주소가_없으면_예외가_발생한다() { - // given - addresses 비어 있는 member - Member memberWithoutAddr = MemberFixture.createMember("주소없는회원", Gender.MALE, Level.A, 2001L, LocalDate.of(1995, 1, 1)); - ReflectionTestUtils.setField(memberWithoutAddr, "id", 2L); - ReflectionTestUtils.setField(memberWithoutAddr, "addresses", new ArrayList<>()); - - given(memberRepository.findMemberWithAddresses(2L)) - .willReturn(Optional.of(memberWithoutAddr)); - - // when & then - assertThatThrownBy(() -> exerciseQueryService.getRecommendedExercises(2L)) - .isInstanceOf(ExerciseException.class) - .satisfies(e -> assertThat(((ExerciseException) e).getCode()) - .isEqualTo(ExerciseErrorCode.MAIN_ADDRESS_NULL)); - } - } - } -} diff --git a/src/test/java/umc/cockple/demo/domain/exercise/service/PartyExerciseQueryServiceTest.java b/src/test/java/umc/cockple/demo/domain/exercise/service/PartyExerciseQueryServiceTest.java new file mode 100644 index 000000000..b9815cff3 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/exercise/service/PartyExerciseQueryServiceTest.java @@ -0,0 +1,331 @@ +package umc.cockple.demo.domain.exercise.service; + +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.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.data.domain.Sort; +import org.springframework.test.util.ReflectionTestUtils; +import umc.cockple.demo.domain.bookmark.repository.ExerciseBookmarkRepository; +import umc.cockple.demo.domain.exercise.converter.ExerciseConverter; +import umc.cockple.demo.domain.exercise.domain.Exercise; +import umc.cockple.demo.domain.exercise.domain.Guest; +import umc.cockple.demo.domain.exercise.dto.ExerciseBuildingDetailDTO; +import umc.cockple.demo.domain.exercise.dto.ExerciseDetailDTO; +import umc.cockple.demo.domain.exercise.dto.ExerciseEditDetailDTO; +import umc.cockple.demo.domain.exercise.dto.ExerciseMapBuildingsDTO; +import umc.cockple.demo.domain.exercise.dto.ExerciseMyGuestListDTO; +import umc.cockple.demo.domain.exercise.dto.MyExerciseCalendarDTO; +import umc.cockple.demo.domain.exercise.dto.MyExerciseListDTO; +import umc.cockple.demo.domain.exercise.dto.MyPartyExerciseCalendarDTO; +import umc.cockple.demo.domain.exercise.dto.MyPartyExerciseDTO; +import umc.cockple.demo.domain.exercise.dto.PartyExerciseCalendarDTO; +import umc.cockple.demo.domain.exercise.enums.MyExerciseFilterType; +import umc.cockple.demo.domain.exercise.enums.MyExerciseOrderType; +import umc.cockple.demo.domain.exercise.enums.MyPartyExerciseOrderType; +import umc.cockple.demo.domain.exercise.exception.ExerciseErrorCode; +import umc.cockple.demo.domain.exercise.exception.ExerciseException; +import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; +import umc.cockple.demo.domain.exercise.repository.GuestRepository; +import umc.cockple.demo.domain.exercise.service.query.lookup.ExerciseParticipantCountLookupService; +import umc.cockple.demo.domain.bookmark.service.query.lookup.ExerciseBookmarkLookupService; +import umc.cockple.demo.domain.exercise.service.support.reader.ExerciseParticipantReader; +import umc.cockple.demo.domain.exercise.service.support.reader.ExerciseReader; +import umc.cockple.demo.domain.exercise.service.support.reader.GuestReader; +import umc.cockple.demo.domain.exercise.service.query.PartyExerciseQueryService; +import umc.cockple.demo.domain.member.service.support.MemberLookupService; +import umc.cockple.demo.domain.party.service.support.PartyLookupService; +import umc.cockple.demo.domain.file.service.FileService; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.domain.MemberExercise; +import umc.cockple.demo.domain.member.domain.MemberParty; +import umc.cockple.demo.domain.member.exception.MemberErrorCode; +import umc.cockple.demo.domain.member.exception.MemberException; +import umc.cockple.demo.domain.member.repository.MemberExerciseRepository; +import umc.cockple.demo.domain.member.repository.MemberPartyRepository; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.domain.party.domain.Party; +import umc.cockple.demo.domain.party.enums.PartyStatus; +import umc.cockple.demo.domain.party.exception.PartyErrorCode; +import umc.cockple.demo.domain.party.exception.PartyException; +import umc.cockple.demo.domain.party.repository.PartyRepository; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.global.enums.Role; +import umc.cockple.demo.support.ExerciseCalendarTestHelper; +import umc.cockple.demo.support.fixture.ExerciseFixture; +import umc.cockple.demo.support.fixture.GuestFixture; +import umc.cockple.demo.support.fixture.MemberFixture; +import umc.cockple.demo.support.fixture.PartyFixture; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.YearMonth; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.groups.Tuple.tuple; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("PartyExerciseQueryService") +class PartyExerciseQueryServiceTest { + + private PartyExerciseQueryService partyExerciseQueryService; + + @Mock private ExerciseRepository exerciseRepository; + @Mock private MemberRepository memberRepository; + @Mock private MemberPartyRepository memberPartyRepository; + @Mock private MemberExerciseRepository memberExerciseRepository; + @Mock private PartyRepository partyRepository; + @Mock private ExerciseBookmarkRepository exerciseBookmarkRepository; + @Mock private FileService fileService; + + private Party party; + private Exercise exercise; + + @BeforeEach + void setUp() { + ExerciseConverter exerciseConverter = new ExerciseConverter(fileService); + partyExerciseQueryService = new PartyExerciseQueryService( + new ExerciseReader(exerciseRepository), + new ExerciseParticipantReader(memberExerciseRepository, memberPartyRepository), + new ExerciseParticipantCountLookupService(exerciseRepository), + new ExerciseBookmarkLookupService(exerciseBookmarkRepository), + new MemberLookupService(memberRepository), + new PartyLookupService(partyRepository), + exerciseConverter + ); + + Member manager = MemberFixture.createMember("모임장", Gender.MALE, Level.A, 1001L); + ReflectionTestUtils.setField(manager, "id", 1L); + + party = PartyFixture.createParty("테스트 모임", manager.getId(), + PartyFixture.createPartyAddr("서울특별시", "강남구")); + ReflectionTestUtils.setField(party, "id", 10L); + + exercise = ExerciseFixture.createExercise(party, LocalDate.now().minusDays(1)); + ReflectionTestUtils.setField(exercise, "id", 100L); + ReflectionTestUtils.setField(exercise, "exerciseAddr", ExerciseFixture.createExerciseAddr()); + } + + @Nested + @DisplayName("getPartyExerciseCalendar") + class GetPartyExerciseCalendar { + + private Member partyMember; + private Member outsiderMember; + private LocalDate startDate; + private LocalDate endDate; + + @BeforeEach + void setUp() { + partyMember = MemberFixture.createMember("파티멤버", Gender.FEMALE, Level.B, 3001L); + ReflectionTestUtils.setField(partyMember, "id", 2L); + + outsiderMember = MemberFixture.createMember("외부멤버", Gender.MALE, Level.C, 3002L); + ReflectionTestUtils.setField(outsiderMember, "id", 3L); + + party.addLevel(Gender.FEMALE, Level.B); + party.addLevel(Gender.MALE, Level.A); + + startDate = LocalDate.of(2026, 3, 23); + endDate = LocalDate.of(2026, 3, 29); + + ReflectionTestUtils.setField(exercise, "date", LocalDate.of(2026, 3, 24)); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("모임 운동 캘린더를 주차별_일자별로 반환한다") + void 모임_운동_캘린더를_주차별_일자별로_반환한다() { + // given + given(partyRepository.findByIdWithLevels(party.getId())) + .willReturn(Optional.of(party)); + given(memberRepository.findById(partyMember.getId())) + .willReturn(Optional.of(partyMember)); + given(memberPartyRepository.existsByPartyAndMember(party, partyMember)) + .willReturn(true); + given(exerciseRepository.findByPartyIdAndDateRange(party.getId(), startDate, endDate)) + .willReturn(List.of(exercise)); + given(exerciseRepository.findExerciseParticipantCounts(party.getId(), startDate, endDate)) + .willReturn(java.util.Collections.singletonList(new Object[]{exercise.getId(), 2})); + given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( + partyMember.getId(), List.of(exercise.getId()))) + .willReturn(List.of(exercise.getId())); + given(memberExerciseRepository.findAllExerciseIdsByMemberAndExerciseIds( + partyMember.getId(), List.of(exercise.getId()))) + .willReturn(List.of(exercise.getId())); + + // when + PartyExerciseCalendarDTO.Response response = partyExerciseQueryService.getPartyExerciseCalendar( + party.getId(), partyMember.getId(), startDate, endDate); + + // then + assertThat(response.startDate()).isEqualTo(startDate); + assertThat(response.endDate()).isEqualTo(endDate); + assertThat(response.isMember()).isTrue(); + assertThat(response.partyName()).isEqualTo(party.getPartyName()); + assertThat(response.weeks()).hasSize(1); + assertThat(response.weeks().get(0).weekStartDate()).isEqualTo(startDate); + assertThat(response.weeks().get(0).weekEndDate()).isEqualTo(endDate); + assertThat(response.weeks().get(0).days()).hasSize(7); + assertThat(response.weeks().get(0).days().get(1).date()) + .isEqualTo(LocalDate.of(2026, 3, 24)); + assertThat(response.weeks().get(0).days().get(1).exercises()) + .extracting( + PartyExerciseCalendarDTO.ExerciseCalendarItem::exerciseId, + PartyExerciseCalendarDTO.ExerciseCalendarItem::isBookmarked, + PartyExerciseCalendarDTO.ExerciseCalendarItem::buildingName, + PartyExerciseCalendarDTO.ExerciseCalendarItem::currentParticipants, + PartyExerciseCalendarDTO.ExerciseCalendarItem::maxCapacity, + PartyExerciseCalendarDTO.ExerciseCalendarItem::isParticipating) + .containsExactly(tuple(exercise.getId(), true, "테스트 체육관", 2, 10, true)); + } + + @Test + @DisplayName("기간 내 운동이 없으면 빈 캘린더를 반환한다") + void 기간_내_운동이_없으면_빈_캘린더를_반환한다() { + // given + given(partyRepository.findByIdWithLevels(party.getId())) + .willReturn(Optional.of(party)); + given(memberRepository.findById(outsiderMember.getId())) + .willReturn(Optional.of(outsiderMember)); + given(memberPartyRepository.existsByPartyAndMember(party, outsiderMember)) + .willReturn(false); + given(exerciseRepository.findByPartyIdAndDateRange(party.getId(), startDate, endDate)) + .willReturn(List.of()); + + // when + PartyExerciseCalendarDTO.Response response = partyExerciseQueryService.getPartyExerciseCalendar( + party.getId(), outsiderMember.getId(), startDate, endDate); + + // then + assertThat(response.startDate()).isEqualTo(startDate); + assertThat(response.endDate()).isEqualTo(endDate); + assertThat(response.isMember()).isFalse(); + assertThat(response.partyName()).isEqualTo(party.getPartyName()); + assertThat(response.weeks()).isEmpty(); + } + + @Test + @DisplayName("시작일과_종료일이_없으면_기본_기간이_적용된다") + void 시작일과_종료일이_없으면_기본_기간이_적용된다() { + // given + LocalDate expectedStart = ExerciseCalendarTestHelper.expectedDefaultStartDate(); + LocalDate expectedEnd = ExerciseCalendarTestHelper.expectedDefaultEndDate(); + + given(partyRepository.findByIdWithLevels(party.getId())) + .willReturn(Optional.of(party)); + given(memberRepository.findById(partyMember.getId())) + .willReturn(Optional.of(partyMember)); + given(memberPartyRepository.existsByPartyAndMember(party, partyMember)) + .willReturn(true); + given(exerciseRepository.findByPartyIdAndDateRange(party.getId(), expectedStart, expectedEnd)) + .willReturn(List.of()); + + // when + PartyExerciseCalendarDTO.Response response = partyExerciseQueryService.getPartyExerciseCalendar( + party.getId(), partyMember.getId(), null, null); + + // then + assertThat(response.startDate()).isEqualTo(expectedStart); + assertThat(response.endDate()).isEqualTo(expectedEnd); + assertThat(response.isMember()).isTrue(); + assertThat(response.weeks()).isEmpty(); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 모임이면 PartyException(PARTY_NOT_FOUND)을 던진다") + void 존재하지_않는_모임이면_예외를_던진다() { + // given + given(partyRepository.findByIdWithLevels(999L)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> partyExerciseQueryService.getPartyExerciseCalendar( + 999L, partyMember.getId(), startDate, endDate)) + .isInstanceOf(PartyException.class) + .hasFieldOrPropertyWithValue("code", PartyErrorCode.PARTY_NOT_FOUND); + verify(memberRepository, never()).findById(any()); + } + + @Test + @DisplayName("존재하지 않는 멤버면 MemberException(MEMBER_NOT_FOUND)을 던진다") + void 존재하지_않는_멤버면_예외를_던진다() { + // given + given(partyRepository.findByIdWithLevels(party.getId())) + .willReturn(Optional.of(party)); + given(memberRepository.findById(999L)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> partyExerciseQueryService.getPartyExerciseCalendar( + party.getId(), 999L, startDate, endDate)) + .isInstanceOf(MemberException.class) + .hasFieldOrPropertyWithValue("code", MemberErrorCode.MEMBER_NOT_FOUND); + } + + @Test + @DisplayName("시작일과 종료일이 함께 오지 않으면 예외를 던진다") + void 시작일과_종료일이_함께_오지_않으면_예외를_던진다() { + // given + given(partyRepository.findByIdWithLevels(party.getId())) + .willReturn(Optional.of(party)); + given(memberRepository.findById(partyMember.getId())) + .willReturn(Optional.of(partyMember)); + + // when & then + assertThatThrownBy(() -> partyExerciseQueryService.getPartyExerciseCalendar( + party.getId(), partyMember.getId(), startDate, null)) + .isInstanceOf(ExerciseException.class) + .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.INCOMPLETE_DATE_RANGE); + } + + @Test + @DisplayName("삭제된 모임이면 예외를 던진다") + void 삭제된_모임이면_예외를_던진다() { + // given + ReflectionTestUtils.setField(party, "status", PartyStatus.INACTIVE); + + given(partyRepository.findByIdWithLevels(party.getId())) + .willReturn(Optional.of(party)); + given(memberRepository.findById(partyMember.getId())) + .willReturn(Optional.of(partyMember)); + + // when & then + assertThatThrownBy(() -> partyExerciseQueryService.getPartyExerciseCalendar( + party.getId(), partyMember.getId(), startDate, endDate)) + .isInstanceOf(PartyException.class) + .hasFieldOrPropertyWithValue("code", PartyErrorCode.PARTY_IS_DELETED); + } + } + } + +} diff --git a/src/test/java/umc/cockple/demo/domain/member/integration/MemberIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/member/integration/MemberIntegrationTest.java index 28d577edc..961f3ce74 100644 --- a/src/test/java/umc/cockple/demo/domain/member/integration/MemberIntegrationTest.java +++ b/src/test/java/umc/cockple/demo/domain/member/integration/MemberIntegrationTest.java @@ -14,6 +14,7 @@ import umc.cockple.demo.domain.contest.enums.MedalType; import umc.cockple.demo.domain.contest.repository.ContestRepository; import umc.cockple.demo.domain.exercise.enums.ExerciseMemberShipStatus; +import umc.cockple.demo.domain.file.repository.ObjectStorageDeleteOutboxRepository; import umc.cockple.demo.domain.file.service.FileService; import umc.cockple.demo.domain.member.domain.*; import umc.cockple.demo.domain.member.dto.CreateMemberAddrDTO; @@ -66,6 +67,7 @@ class MemberIntegrationTest extends IntegrationTestBase { @Autowired MemberKeywordRepository memberKeywordRepository; @Autowired ChatRoomRepository chatRoomRepository; @Autowired ChatRoomMemberRepository chatRoomMemberRepository; + @Autowired ObjectStorageDeleteOutboxRepository objectStorageDeleteOutboxRepository; private Member member; @@ -76,6 +78,7 @@ void setUp() { @AfterEach void tearDown() { + objectStorageDeleteOutboxRepository.deleteAll(); // 프로필 이미지 교체 시 적재된 삭제 outbox 정리 chatRoomRepository.deleteAll(); // cascade: ChatRoomMember 함께 삭제 memberPartyRepository.deleteAll(); partyRepository.deleteAll(); diff --git a/src/test/java/umc/cockple/demo/domain/member/integration/MemberProfileQueryCountTest.java b/src/test/java/umc/cockple/demo/domain/member/integration/MemberProfileQueryCountTest.java new file mode 100644 index 000000000..c0357622b --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/member/integration/MemberProfileQueryCountTest.java @@ -0,0 +1,124 @@ +package umc.cockple.demo.domain.member.integration; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +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 umc.cockple.demo.domain.contest.enums.MedalType; +import umc.cockple.demo.domain.contest.repository.ContestRepository; +import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.repository.MemberAddrRepository; +import umc.cockple.demo.domain.member.repository.MemberExerciseRepository; +import umc.cockple.demo.domain.member.repository.MemberPartyRepository; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.domain.member.service.MemberQueryService; +import umc.cockple.demo.domain.party.domain.Party; +import umc.cockple.demo.domain.party.domain.PartyAddr; +import umc.cockple.demo.domain.party.repository.PartyAddrRepository; +import umc.cockple.demo.domain.party.repository.PartyRepository; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.global.enums.Role; +import umc.cockple.demo.support.IntegrationTestBase; +import umc.cockple.demo.support.fixture.ContestFixture; +import umc.cockple.demo.support.fixture.ExerciseFixture; +import umc.cockple.demo.support.fixture.MemberAddrFixture; +import umc.cockple.demo.support.fixture.MemberFixture; +import umc.cockple.demo.support.fixture.PartyFixture; + +import java.time.LocalDate; + +import static umc.cockple.demo.support.QueryCountAssert.assertQueryCount; + +// 프로필 조회 최적화(대회/모임/운동 컬렉션 전량 로딩 → 집계·COUNT 쿼리) 회귀 방지 테스트 +@DisplayName("프로필 조회 쿼리 카운트 테스트") +class MemberProfileQueryCountTest extends IntegrationTestBase { + + /** + * getProfile 기대 쿼리 수 (대회/모임 개수와 무관하게 고정) + * 1) 회원 2) 메달 집계 3) 가입 모임 수 COUNT + * (프로필 이미지가 없는 회원이라 이미지 조회 쿼리는 발생하지 않는다) + */ + private static final int GET_PROFILE_QUERY_COUNT = 3; + + /** + * getMyProfile 기대 쿼리 수 (대회/모임/운동 개수와 무관하게 고정) + * getProfile(3) + 대표주소 + 참여 운동 수 COUNT + 키워드 + */ + private static final int GET_MY_PROFILE_QUERY_COUNT = 6; + + @Autowired MemberQueryService memberQueryService; + @Autowired MemberRepository memberRepository; + @Autowired MemberAddrRepository memberAddrRepository; + @Autowired ContestRepository contestRepository; + @Autowired PartyRepository partyRepository; + @Autowired PartyAddrRepository partyAddrRepository; + @Autowired ExerciseRepository exerciseRepository; + @Autowired MemberPartyRepository memberPartyRepository; + @Autowired MemberExerciseRepository memberExerciseRepository; + + @PersistenceContext EntityManager em; + + @AfterEach + void tearDown() { + memberExerciseRepository.deleteAll(); + contestRepository.deleteAll(); + memberPartyRepository.deleteAll(); + exerciseRepository.deleteAll(); + partyRepository.deleteAll(); + partyAddrRepository.deleteAll(); + memberAddrRepository.deleteAll(); + memberRepository.deleteAll(); + } + + @Test + @DisplayName("getProfile - 대회/모임 개수와 무관하게 고정된 쿼리 수만 실행한다") + void getProfile_hasNoNPlusOne() { + Member small = seedMember(3001L, 2); + Member large = seedMember(3002L, 10); + + assertQueryCount(em, GET_PROFILE_QUERY_COUNT, () -> + memberQueryService.getProfile(small.getId())); + assertQueryCount(em, GET_PROFILE_QUERY_COUNT, () -> + memberQueryService.getProfile(large.getId())); + } + + @Test + @DisplayName("getMyProfile - 대회/모임/운동 개수와 무관하게 고정된 쿼리 수만 실행한다") + void getMyProfile_hasNoNPlusOne() { + Member small = seedMember(3003L, 2); + Member large = seedMember(3004L, 10); + + assertQueryCount(em, GET_MY_PROFILE_QUERY_COUNT, () -> + memberQueryService.getMyProfile(small.getId())); + assertQueryCount(em, GET_MY_PROFILE_QUERY_COUNT, () -> + memberQueryService.getMyProfile(large.getId())); + } + + // === 시딩 === + + // 회원 + 대표주소 1개 + 대회 n개 + (모임 n개 가입) + (운동 n개 참여) + private Member seedMember(long socialId, int n) { + Member member = memberRepository.save( + MemberFixture.createMember("회원" + socialId, Gender.MALE, Level.A, socialId)); + + memberAddrRepository.save( + MemberAddrFixture.createAddr(member, "역삼동", "서울특별시 강남구 테헤란로 1", true)); + + for (int i = 0; i < n; i++) { + contestRepository.save(ContestFixture.createContest(member, "대회" + socialId + "_" + i, MedalType.GOLD)); + + PartyAddr addr = partyAddrRepository.save(PartyFixture.createPartyAddr("경기도", "모임" + socialId + "_" + i)); + Party party = partyRepository.save(PartyFixture.createParty("모임" + socialId + "_" + i, member.getId(), addr)); + memberPartyRepository.save(MemberFixture.createMemberParty(party, member, Role.PARTY_MEMBER)); + + var exercise = exerciseRepository.save( + ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().plusDays(i + 1))); + memberExerciseRepository.save(MemberFixture.createMemberExercise(member, exercise)); + } + return member; + } +} diff --git a/src/test/java/umc/cockple/demo/domain/member/integration/ProfileImgUniqueViolationIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/member/integration/ProfileImgUniqueViolationIntegrationTest.java new file mode 100644 index 000000000..ca0646a04 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/member/integration/ProfileImgUniqueViolationIntegrationTest.java @@ -0,0 +1,77 @@ +package umc.cockple.demo.domain.member.integration; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.hibernate.exception.ConstraintViolationException; +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.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.domain.ProfileImg; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.support.IntegrationTestBase; +import umc.cockple.demo.support.fixture.MemberFixture; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; + +/** + * MemberProfileUpdateExecutor 가 동시성 충돌을 식별하는 근거(제약명)가 실제 DB 에서 유효한지 보장하는 회귀 테스트. + * 제약 이름이 바뀌거나 노출 형식이 달라지면(예: 테이블 접두사 제거) executor 의 부분 매칭과 함께 여기서 깨진다. + */ +class ProfileImgUniqueViolationIntegrationTest extends IntegrationTestBase { + + @Autowired MemberRepository memberRepository; + @Autowired PlatformTransactionManager transactionManager; + @PersistenceContext EntityManager em; + + private Long memberId; + + @AfterEach + void tearDown() { + if (memberId != null) { + memberRepository.deleteById(memberId); + } + } + + @Test + @DisplayName("같은 member 에 profile_img 를 2개 INSERT 하면 uq_profile_img_member 제약 위반이 cause 체인에 노출된다") + void duplicate_profile_img_exposes_constraint_name() { + Member saved = memberRepository.save( + MemberFixture.createMember("진단", Gender.MALE, Level.A, 777777L)); + memberId = saved.getId(); + + TransactionTemplate tx = new TransactionTemplate(transactionManager); + + Throwable thrown = catchThrowable(() -> + tx.executeWithoutResult(status -> { + Member m = memberRepository.findById(memberId).orElseThrow(); + ProfileImg p1 = ProfileImg.builder().imgKey("a").build(); + p1.setMember(m); + em.persist(p1); + ProfileImg p2 = ProfileImg.builder().imgKey("b").build(); + p2.setMember(m); + em.persist(p2); + }) + ); + + assertThat(thrown).isNotNull(); + assertThat(constraintNameInChain(thrown)) + .as("executor 가 부분 매칭하는 제약명이 cause 체인에 존재해야 한다") + .containsIgnoringCase("uq_profile_img_member"); + } + + private String constraintNameInChain(Throwable e) { + for (Throwable t = e; t != null && t != t.getCause(); t = t.getCause()) { + if (t instanceof ConstraintViolationException cve && cve.getConstraintName() != null) { + return cve.getConstraintName(); + } + } + return ""; + } +} diff --git a/src/test/java/umc/cockple/demo/domain/member/service/MemberCommandServiceTest.java b/src/test/java/umc/cockple/demo/domain/member/service/MemberCommandServiceTest.java index 82803ac9c..3ac62b18e 100644 --- a/src/test/java/umc/cockple/demo/domain/member/service/MemberCommandServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/member/service/MemberCommandServiceTest.java @@ -14,7 +14,7 @@ import umc.cockple.demo.domain.chat.domain.ChatRoomMember; import umc.cockple.demo.domain.chat.repository.ChatRoomMemberRepository; import umc.cockple.demo.support.fixture.ChatFixture; -import umc.cockple.demo.domain.file.service.FileService; +import umc.cockple.demo.domain.file.service.ObjectStorageDeleteOutboxService; import umc.cockple.demo.domain.member.domain.Member; import umc.cockple.demo.domain.member.domain.MemberAddr; import umc.cockple.demo.domain.member.domain.MemberParty; @@ -27,6 +27,7 @@ import umc.cockple.demo.domain.member.exception.MemberException; import umc.cockple.demo.domain.member.events.MemberWithdrawnEvent; import umc.cockple.demo.domain.member.repository.*; +import umc.cockple.demo.global.auth.TokenVersionRepository; import umc.cockple.demo.global.enums.Gender; import umc.cockple.demo.global.enums.Keyword; import umc.cockple.demo.global.enums.Level; @@ -64,8 +65,9 @@ class MemberCommandServiceTest { @Mock private MemberAddrRepository memberAddrRepository; @Mock private ChatRoomMemberRepository chatRoomMemberRepository; @Mock private ApplicationEventPublisher applicationEventPublisher; - @Mock private FileService fileService; + @Mock private ObjectStorageDeleteOutboxService objectStorageDeleteOutboxService; @Mock private KakaoOauthService kakaoOauthService; + @Mock private TokenVersionRepository tokenVersionRepository; private Member normalMember; @@ -253,7 +255,8 @@ class Success { memberCommandService.updateProfile(requestWithImg, normalMember.getId()); // then - then(fileService).should().delete("profile/old-key.jpg"); + then(objectStorageDeleteOutboxService).should() + .enqueueProfileImage(normalMember.getId(), "profile/old-key.jpg"); } @Test @@ -277,7 +280,7 @@ class Success { memberCommandService.updateProfile(sameImgRequest, normalMember.getId()); // then - then(fileService).should(never()).delete(any()); + then(objectStorageDeleteOutboxService).should(never()).enqueueProfileImage(any(), any()); } @Test diff --git a/src/test/java/umc/cockple/demo/domain/member/service/MemberProfileUpdateExecutorTest.java b/src/test/java/umc/cockple/demo/domain/member/service/MemberProfileUpdateExecutorTest.java new file mode 100644 index 000000000..b07617ab8 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/member/service/MemberProfileUpdateExecutorTest.java @@ -0,0 +1,136 @@ +package umc.cockple.demo.domain.member.service; + +import org.hibernate.exception.ConstraintViolationException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import umc.cockple.demo.domain.member.exception.MemberErrorCode; +import umc.cockple.demo.domain.member.exception.MemberException; + +import java.sql.SQLException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.catchThrowable; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("MemberProfileUpdateExecutor") +class MemberProfileUpdateExecutorTest { + + @Mock + private MemberCommandService memberCommandService; + + @InjectMocks + private MemberProfileUpdateExecutor executor; + + /** profile_img 유니크 제약 위반 (MySQL8 형식: 테이블 접두사 포함). */ + private DataIntegrityViolationException profileUniqueConflict() { + ConstraintViolationException cve = new ConstraintViolationException( + "could not execute statement", + new SQLException("Duplicate entry '1' for key 'profile_img.uq_profile_img_member'", "23000", 1062), + "profile_img.uq_profile_img_member"); + return new DataIntegrityViolationException("duplicate", cve); + } + + /** 프로필과 무관한 다른 무결성 위반 (예: 다른 제약). */ + private DataIntegrityViolationException otherConstraintConflict() { + ConstraintViolationException cve = new ConstraintViolationException( + "could not execute statement", + new SQLException("Cannot add or update a child row", "23000", 1452), + "fk_some_other_constraint"); + return new DataIntegrityViolationException("other", cve); + } + + @Nested + @DisplayName("updateProfile - 동시성 충돌 재시도") + class UpdateProfileRetry { + + @Test + @DisplayName("프로필 유니크 충돌이 한 번 나면 재시도해서 성공한다") + void 유니크_충돌_후_재시도_성공() { + doThrow(profileUniqueConflict()).doNothing() + .when(memberCommandService).updateProfile(any(), any()); + + executor.updateProfile(null, 1L); + + verify(memberCommandService, times(2)).updateProfile(any(), any()); + } + + @Test + @DisplayName("낙관적 락 충돌이 한 번 나면 재시도해서 성공한다") + void 낙관적락_충돌_후_재시도_성공() { + doThrow(new ObjectOptimisticLockingFailureException("stale", new RuntimeException())) + .doNothing() + .when(memberCommandService).updateProfile(any(), any()); + + executor.updateProfile(null, 1L); + + verify(memberCommandService, times(2)).updateProfile(any(), any()); + } + + @Test + @DisplayName("충돌이 최대 시도까지 계속되면 PROFILE_UPDATE_CONFLICT(409)를 던진다") + void 충돌_지속시_409() { + doThrow(profileUniqueConflict()) + .when(memberCommandService).updateProfile(any(), any()); + + Throwable thrown = catchThrowable(() -> executor.updateProfile(null, 1L)); + + assertThat(thrown).isInstanceOfSatisfying(MemberException.class, + e -> assertThat(e.getCode()).isEqualTo(MemberErrorCode.PROFILE_UPDATE_CONFLICT)); + verify(memberCommandService, times(3)).updateProfile(any(), any()); + } + + @Test + @DisplayName("프로필과 무관한 무결성 위반은 재시도하지 않고 원래 예외를 그대로 전파한다") + void 다른_제약위반은_그대로_전파() { + DataIntegrityViolationException other = otherConstraintConflict(); + doThrow(other).when(memberCommandService).updateProfile(any(), any()); + + Throwable thrown = catchThrowable(() -> executor.updateProfile(null, 1L)); + + assertThat(thrown) + .isInstanceOf(DataIntegrityViolationException.class) + .isNotInstanceOf(MemberException.class); + verify(memberCommandService, times(1)).updateProfile(any(), any()); + } + } + + @Nested + @DisplayName("registerMemberDetailInfo - 온보딩도 동일한 재시도 보호를 받는다") + class OnboardingRetry { + + @Test + @DisplayName("프로필 유니크 충돌이 한 번 나면 재시도해서 성공한다") + void 온보딩_유니크_충돌_후_재시도_성공() { + doThrow(profileUniqueConflict()).doNothing() + .when(memberCommandService).memberDetailInfo(any(), any()); + + executor.registerMemberDetailInfo(1L, null); + + verify(memberCommandService, times(2)).memberDetailInfo(any(), any()); + } + + @Test + @DisplayName("프로필과 무관한 무결성 위반은 그대로 전파한다") + void 온보딩_다른_제약위반은_전파() { + doThrow(otherConstraintConflict()).when(memberCommandService).memberDetailInfo(any(), any()); + + assertThatThrownBy(() -> executor.registerMemberDetailInfo(1L, null)) + .isInstanceOf(DataIntegrityViolationException.class) + .isNotInstanceOf(MemberException.class); + verify(memberCommandService, times(1)).memberDetailInfo(any(), any()); + } + } +} diff --git a/src/test/java/umc/cockple/demo/domain/member/service/MemberQueryServiceTest.java b/src/test/java/umc/cockple/demo/domain/member/service/MemberQueryServiceTest.java index 1e5209436..5391a11d2 100644 --- a/src/test/java/umc/cockple/demo/domain/member/service/MemberQueryServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/member/service/MemberQueryServiceTest.java @@ -9,8 +9,8 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; -import umc.cockple.demo.domain.contest.domain.Contest; -import umc.cockple.demo.domain.contest.enums.MedalType; +import umc.cockple.demo.domain.contest.dto.ContestMedalSummaryDTO; +import umc.cockple.demo.domain.contest.service.ContestQueryService; import umc.cockple.demo.domain.file.service.FileService; import umc.cockple.demo.domain.member.domain.Member; import umc.cockple.demo.domain.member.domain.MemberAddr; @@ -23,6 +23,8 @@ import umc.cockple.demo.domain.member.dto.OnboardingStatusResponseDTO; import umc.cockple.demo.domain.member.exception.MemberErrorCode; import umc.cockple.demo.domain.member.exception.MemberException; +import umc.cockple.demo.domain.member.repository.MemberExerciseRepository; +import umc.cockple.demo.domain.member.repository.MemberPartyRepository; import umc.cockple.demo.domain.member.repository.MemberRepository; import umc.cockple.demo.global.enums.Gender; import umc.cockple.demo.global.enums.Keyword; @@ -47,10 +49,22 @@ class MemberQueryServiceTest { private MemberQueryService memberQueryService; @Mock private MemberRepository memberRepository; + @Mock private MemberPartyRepository memberPartyRepository; + @Mock private MemberExerciseRepository memberExerciseRepository; + @Mock private ContestQueryService contestQueryService; @Mock private FileService fileService; private Member member; + private static ContestMedalSummaryDTO.Response medals(int gold, int silver, int bronze) { + return ContestMedalSummaryDTO.Response.builder() + .myMedalTotal(gold + silver + bronze) + .goldCount(gold) + .silverCount(silver) + .bronzeCount(bronze) + .build(); + } + @BeforeEach void setUp() { member = MemberFixture.createMember("강와나", Gender.FEMALE, Level.A, 1001L); @@ -75,6 +89,7 @@ class Success { ReflectionTestUtils.setField(member, "profileImg", profileImg); given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(contestQueryService.getMyMedalSummary(member.getId())).willReturn(medals(0, 0, 0)); given(fileService.getUrlFromKey("profile/test-key.jpg")) .willReturn("https://cdn.example.com/profile/test-key.jpg"); @@ -91,6 +106,7 @@ class Success { void profileImg가_없으면_imgUrl이_null로_반환된다() { // given given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(contestQueryService.getMyMedalSummary(member.getId())).willReturn(medals(0, 0, 0)); // when GetProfileResponseDTO response = memberQueryService.getProfile(member.getId()); @@ -101,17 +117,11 @@ class Success { } @Test - @DisplayName("금_은_동_메달_개수가_올바르게_집계된다") + @DisplayName("금_은_동_메달_개수가_contest_집계_결과로_반환된다") void 금_은_동_메달_개수가_올바르게_집계된다() { // given - Contest gold1 = Contest.builder().medalType(MedalType.GOLD).build(); - Contest gold2 = Contest.builder().medalType(MedalType.GOLD).build(); - Contest silver = Contest.builder().medalType(MedalType.SILVER).build(); - Contest bronze = Contest.builder().medalType(MedalType.BRONZE).build(); - - member.getContests().addAll(List.of(gold1, gold2, silver, bronze)); - given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(contestQueryService.getMyMedalSummary(member.getId())).willReturn(medals(2, 1, 1)); // when GetProfileResponseDTO response = memberQueryService.getProfile(member.getId()); @@ -123,13 +133,12 @@ class Success { } @Test - @DisplayName("참여한_모임_수가_올바르게_반환된다") + @DisplayName("참여한_모임_수가_COUNT_쿼리_결과로_반환된다") void 참여한_모임_수가_올바르게_반환된다() { // given - member.getMemberParties().add(MemberFixture.createMemberParty(null, member, umc.cockple.demo.global.enums.Role.PARTY_MEMBER)); - member.getMemberParties().add(MemberFixture.createMemberParty(null, member, umc.cockple.demo.global.enums.Role.PARTY_MEMBER)); - given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(contestQueryService.getMyMedalSummary(member.getId())).willReturn(medals(0, 0, 0)); + given(memberPartyRepository.countByMember_Id(member.getId())).willReturn(2L); // when GetProfileResponseDTO response = memberQueryService.getProfile(member.getId()); @@ -218,9 +227,6 @@ class Success { MemberAddr mainAddr = MemberAddrFixture.createAddr(member, "역삼동", "서울특별시 강남구 테헤란로 1", true); member.getAddresses().add(mainAddr); - MemberExercise exercise = MemberFixture.createMemberExercise(member, null); - member.getMemberExercises().add(exercise); - umc.cockple.demo.domain.member.domain.MemberKeyword keyword = umc.cockple.demo.domain.member.domain.MemberKeyword.builder() .member(member) @@ -229,6 +235,8 @@ class Success { member.getKeywords().add(keyword); given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(contestQueryService.getMyMedalSummary(member.getId())).willReturn(medals(0, 0, 0)); + given(memberExerciseRepository.countByMember_Id(member.getId())).willReturn(1L); // when GetMyProfileResponseDTO response = memberQueryService.getMyProfile(member.getId()); diff --git a/src/test/java/umc/cockple/demo/domain/notification/events/NotificationEventListenerTest.java b/src/test/java/umc/cockple/demo/domain/notification/events/NotificationEventListenerTest.java index acfff275d..9d8edc11c 100644 --- a/src/test/java/umc/cockple/demo/domain/notification/events/NotificationEventListenerTest.java +++ b/src/test/java/umc/cockple/demo/domain/notification/events/NotificationEventListenerTest.java @@ -8,9 +8,9 @@ import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Primary; import org.springframework.core.task.SyncTaskExecutor; import org.springframework.core.task.TaskExecutor; +import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.transaction.support.TransactionTemplate; @@ -27,14 +27,15 @@ import static org.mockito.Mockito.never; @DisplayName("NotificationEventListener") +// 알림 @Async 풀(notificationExecutor)을 SyncTaskExecutor로 덮어써 동기 실행하려면 빈 오버라이드 허용 필요 +@TestPropertySource(properties = "spring.main.allow-bean-definition-overriding=true") class NotificationEventListenerTest extends IntegrationTestBase { - // @Async를 동기로 실행해 테스트 스레드와 타이밍 문제를 제거 + // handleNotification의 @Async("notificationExecutor")를 동기로 실행해 테스트 타이밍 문제를 제거 @TestConfiguration static class SyncAsyncConfig { @Bean - @Primary - public TaskExecutor taskExecutor() { + public TaskExecutor notificationExecutor() { return new SyncTaskExecutor(); } } diff --git a/src/test/java/umc/cockple/demo/domain/notification/integration/NotificationUnreadQueryCountTest.java b/src/test/java/umc/cockple/demo/domain/notification/integration/NotificationUnreadQueryCountTest.java new file mode 100644 index 000000000..5908f6057 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/notification/integration/NotificationUnreadQueryCountTest.java @@ -0,0 +1,99 @@ +package umc.cockple.demo.domain.notification.integration; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +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 umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.domain.notification.domain.Notification; +import umc.cockple.demo.domain.notification.dto.ExistNewNotificationResponseDTO; +import umc.cockple.demo.domain.notification.enums.NotificationType; +import umc.cockple.demo.domain.notification.repository.NotificationRepository; +import umc.cockple.demo.domain.notification.service.NotificationQueryService; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.support.IntegrationTestBase; +import umc.cockple.demo.support.fixture.MemberFixture; + +import static org.assertj.core.api.Assertions.assertThat; +import static umc.cockple.demo.support.QueryCountAssert.assertQueryCount; + +// 안 읽은 알림 존재여부 조회 최적화(알림 전량 로딩 → EXISTS 1회) 회귀 방지 테스트 +@DisplayName("안 읽은 알림 존재여부 조회 쿼리 카운트 테스트") +class NotificationUnreadQueryCountTest extends IntegrationTestBase { + + /** + * checkUnreadNotification의 기대 쿼리 수 + * 회원/알림 컬렉션 로딩 없이 EXISTS 한 번만 실행하므로, 알림 개수와 무관하게 1이어야 한다. + */ + private static final int UNREAD_CHECK_QUERY_COUNT = 1; + + @Autowired NotificationQueryService notificationQueryService; + @Autowired MemberRepository memberRepository; + @Autowired NotificationRepository notificationRepository; + + @PersistenceContext EntityManager em; + + @AfterEach + void tearDown() { + notificationRepository.deleteAll(); + memberRepository.deleteAll(); + } + + @Test + @DisplayName("알림 개수와 무관하게 항상 1개의 쿼리만 실행한다") + void checkUnreadNotification_runsSingleQuery() { + Member member = createMember(9001L); + // 읽은 알림 5, 안 읽은 알림 3 + for (int i = 0; i < 5; i++) seedNotification(member, true, i); + for (int i = 0; i < 3; i++) seedNotification(member, false, i); + + assertQueryCount(em, UNREAD_CHECK_QUERY_COUNT, () -> + notificationQueryService.checkUnreadNotification(member.getId())); + } + + @Test + @DisplayName("안 읽은 알림이 있으면 true, 모두 읽었으면 false를 반환한다") + void checkUnreadNotification_returnsCorrectResult() { + Member hasUnread = createMember(9002L); + seedNotification(hasUnread, true, 0); + seedNotification(hasUnread, false, 1); + + Member allRead = createMember(9003L); + seedNotification(allRead, true, 0); + seedNotification(allRead, true, 1); + + Member noNotifications = createMember(9004L); + + assertThat(check(hasUnread)).isTrue(); + assertThat(check(allRead)).isFalse(); + assertThat(check(noNotifications)).isFalse(); + } + + // === 헬퍼 === + + private boolean check(Member member) { + ExistNewNotificationResponseDTO response = + notificationQueryService.checkUnreadNotification(member.getId()); + return response.existNewNotification(); + } + + private Member createMember(long socialId) { + return memberRepository.save( + MemberFixture.createMember("회원" + socialId, Gender.MALE, Level.A, socialId)); + } + + private void seedNotification(Member member, boolean isRead, int idx) { + notificationRepository.save(Notification.builder() + .member(member) + .partyId(100L) + .title("알림" + idx) + .content("내용" + idx) + .type(NotificationType.SIMPLE) + .isRead(isRead) + .build()); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/notification/service/NotificationQueryServiceTest.java b/src/test/java/umc/cockple/demo/domain/notification/service/NotificationQueryServiceTest.java index 8aef6bdc5..78ed910fe 100644 --- a/src/test/java/umc/cockple/demo/domain/notification/service/NotificationQueryServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/notification/service/NotificationQueryServiceTest.java @@ -212,8 +212,7 @@ class Success { @DisplayName("읽지 않은 알림이 있으면 existNewNotification이 true이다") void hasUnreadNotification_returnsTrue() { // given - ReflectionTestUtils.setField(member, "notifications", List.of(notification)); - given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(notificationRepository.existsByMember_IdAndIsReadFalse(member.getId())).willReturn(true); // when ExistNewNotificationResponseDTO result = notificationQueryService.checkUnreadNotification(member.getId()); @@ -223,12 +222,10 @@ void hasUnreadNotification_returnsTrue() { } @Test - @DisplayName("모든 알림이 읽힌 상태이면 existNewNotification이 false이다") - void allNotificationsRead_returnsFalse() { + @DisplayName("읽지 않은 알림이 없으면 existNewNotification이 false이다") + void noUnreadNotification_returnsFalse() { // given - notification.read(); - ReflectionTestUtils.setField(member, "notifications", List.of(notification)); - given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(notificationRepository.existsByMember_IdAndIsReadFalse(member.getId())).willReturn(false); // when ExistNewNotificationResponseDTO result = notificationQueryService.checkUnreadNotification(member.getId()); @@ -236,38 +233,6 @@ void allNotificationsRead_returnsFalse() { // then assertThat(result.existNewNotification()).isFalse(); } - - @Test - @DisplayName("알림이 없으면 existNewNotification이 false이다") - void noNotifications_returnsFalse() { - // given - ReflectionTestUtils.setField(member, "notifications", List.of()); - given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); - - // when - ExistNewNotificationResponseDTO result = notificationQueryService.checkUnreadNotification(member.getId()); - - // then - assertThat(result.existNewNotification()).isFalse(); - } - } - - @Nested - @DisplayName("실패 케이스") - class Failure { - - @Test - @DisplayName("존재하지 않는 회원이면 MemberException(MEMBER_NOT_FOUND)을 던진다") - void memberNotFound_throwsMemberException() { - // given - given(memberRepository.findById(999L)).willReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> notificationQueryService.checkUnreadNotification(999L)) - .isInstanceOf(MemberException.class) - .satisfies(e -> assertThat(((MemberException) e).getCode()) - .isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND)); - } } } } diff --git a/src/test/java/umc/cockple/demo/global/auth/RefreshTokenRepositoryConsumeAndMarkTest.java b/src/test/java/umc/cockple/demo/global/auth/RefreshTokenRepositoryConsumeAndMarkTest.java new file mode 100644 index 000000000..abbb9486a --- /dev/null +++ b/src/test/java/umc/cockple/demo/global/auth/RefreshTokenRepositoryConsumeAndMarkTest.java @@ -0,0 +1,44 @@ +package umc.cockple.demo.global.auth; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import umc.cockple.demo.support.IntegrationTestBase; + +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("RefreshTokenRepository.consumeAndMark - GETDEL+소비마커 원자 스크립트") +class RefreshTokenRepositoryConsumeAndMarkTest extends IntegrationTestBase { + + @Autowired + RefreshTokenRepository refreshTokenRepository; + + @Test + @DisplayName("활성 토큰을 소비하면 memberId 반환 + 활성키 삭제 + 소비 마커 기록을 원자적으로 수행한다") + void consumeActiveToken() { + String token = "rt-" + UUID.randomUUID(); + refreshTokenRepository.save(token, 42L); + + Optional result = refreshTokenRepository.consumeAndMark(token); + + assertThat(result).contains(42L); + // 활성 키가 삭제되어 두 번째 소비는 empty (GETDEL 의미) + assertThat(refreshTokenRepository.consumeAndMark(token)).isEmpty(); + // 삭제와 동시에 소비 마커가 원자적으로 기록됨 (동시 재발급 오탐 방지의 핵심) + assertThat(refreshTokenRepository.isRecentlyConsumed(token)).isTrue(); + } + + @Test + @DisplayName("존재하지 않는 토큰이면 empty 반환 + 소비 마커도 남기지 않는다") + void consumeMissingToken() { + String token = "rt-" + UUID.randomUUID(); + + Optional result = refreshTokenRepository.consumeAndMark(token); + + assertThat(result).isEmpty(); + assertThat(refreshTokenRepository.isRecentlyConsumed(token)).isFalse(); + } +} diff --git a/src/test/java/umc/cockple/demo/global/auth/TokenVersionRepositoryDbFallbackTest.java b/src/test/java/umc/cockple/demo/global/auth/TokenVersionRepositoryDbFallbackTest.java new file mode 100644 index 000000000..648333ede --- /dev/null +++ b/src/test/java/umc/cockple/demo/global/auth/TokenVersionRepositoryDbFallbackTest.java @@ -0,0 +1,70 @@ +package umc.cockple.demo.global.auth; + +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.data.redis.core.StringRedisTemplate; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.support.IntegrationTestBase; +import umc.cockple.demo.support.fixture.MemberFixture; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("TokenVersionRepository - DB(SoT) fallback + Redis 캐시") +class TokenVersionRepositoryDbFallbackTest extends IntegrationTestBase { + + private static final String KEY_PREFIX = "member:tokenVersion:"; + + @Autowired TokenVersionRepository tokenVersionRepository; + @Autowired MemberRepository memberRepository; + @Autowired StringRedisTemplate redis; + + private Long memberId; + + @AfterEach + void tearDown() { + if (memberId != null) { + redis.delete(KEY_PREFIX + memberId); + memberRepository.deleteById(memberId); + } + } + + @Test + @DisplayName("Redis 캐시 miss여도 DB의 token_version을 읽어 반환하고 캐시에 재적재한다") + void getVersionFallsBackToDbAndRepopulatesCache() { + Member member = memberRepository.save( + MemberFixture.createMember("버전회원", Gender.MALE, Level.A, 990001L)); + memberId = member.getId(); + + // 무효화 발생 -> DB=1, Redis=1 + tokenVersionRepository.increment(memberId); + // Redis 키가 eviction/유실된 상황 재현 + redis.delete(KEY_PREFIX + memberId); + + long version = tokenVersionRepository.getVersion(memberId); + + // 기존 코드였다면 miss=0으로 오판했을 값을 DB(SoT)에서 1로 복구 + assertThat(version).isEqualTo(1L); + // 캐시에 재적재됨 + assertThat(redis.opsForValue().get(KEY_PREFIX + memberId)).isEqualTo("1"); + } + + @Test + @DisplayName("increment는 DB를 원자적으로 올리고 캐시도 갱신한다") + void incrementUpdatesDbAndCache() { + Member member = memberRepository.save( + MemberFixture.createMember("증가회원", Gender.MALE, Level.A, 990002L)); + memberId = member.getId(); + + long v1 = tokenVersionRepository.increment(memberId); + long v2 = tokenVersionRepository.increment(memberId); + + assertThat(v1).isEqualTo(1L); + assertThat(v2).isEqualTo(2L); + assertThat(redis.opsForValue().get(KEY_PREFIX + memberId)).isEqualTo("2"); + } +} diff --git a/src/test/java/umc/cockple/demo/global/config/AsyncConfigTest.java b/src/test/java/umc/cockple/demo/global/config/AsyncConfigTest.java new file mode 100644 index 000000000..a725ba7a1 --- /dev/null +++ b/src/test/java/umc/cockple/demo/global/config/AsyncConfigTest.java @@ -0,0 +1,77 @@ +package umc.cockple.demo.global.config; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; +import org.springframework.boot.task.ThreadPoolTaskExecutorBuilder; +import org.springframework.core.task.TaskDecorator; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import umc.cockple.demo.global.logging.MdcLoggingFilter; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +class AsyncConfigTest { + + private final AsyncConfig config = new AsyncConfig(); + + @AfterEach + void tearDown() { + MDC.clear(); + } + + @Test + @DisplayName("applicationTaskExecutor는 MDC를 비동기 작업에 전파하고 다음 작업으로 누수하지 않는다") + void applicationTaskExecutorPropagatesMdcAndPreventsLeak() throws Exception { + ThreadPoolTaskExecutor executor = config.applicationTaskExecutor( + new ThreadPoolTaskExecutorBuilder() + .corePoolSize(1) + .maxPoolSize(1) + .queueCapacity(1), + config.mdcTaskDecorator() + ); + + assertPropagatesMdcAndPreventsLeak(executor); + } + + @Test + @DisplayName("chatExecutor는 MDC를 비동기 작업에 전파하고 다음 작업으로 누수하지 않는다") + void chatExecutorPropagatesMdcAndPreventsLeak() throws Exception { + ThreadPoolTaskExecutor executor = config.chatExecutor(1, 1, 1, 1, config.mdcTaskDecorator()); + + assertPropagatesMdcAndPreventsLeak(executor); + } + + @Test + @DisplayName("notificationExecutor는 MDC를 비동기 작업에 전파하고 다음 작업으로 누수하지 않는다") + void notificationExecutorPropagatesMdcAndPreventsLeak() throws Exception { + ThreadPoolTaskExecutor executor = config.notificationExecutor(1, 1, 1, 1, config.mdcTaskDecorator()); + + assertPropagatesMdcAndPreventsLeak(executor); + } + + /** + * 풀에서 도는 작업에 requestId(MDC)가 전파되고, 스레드 재사용 시 이전 컨텍스트가 누수되지 않는지 검증한다. + */ + private void assertPropagatesMdcAndPreventsLeak(ThreadPoolTaskExecutor executor) throws Exception { + executor.initialize(); + try { + MDC.put(MdcLoggingFilter.REQUEST_ID, "async-request"); + CompletableFuture firstTask = new CompletableFuture<>(); + executor.execute(() -> firstTask.complete(MDC.get(MdcLoggingFilter.REQUEST_ID))); + + assertThat(firstTask.get(3, TimeUnit.SECONDS)).isEqualTo("async-request"); + + MDC.clear(); + CompletableFuture secondTask = new CompletableFuture<>(); + executor.execute(() -> secondTask.complete(MDC.get(MdcLoggingFilter.REQUEST_ID))); + + assertThat(secondTask.get(3, TimeUnit.SECONDS)).isNull(); + } finally { + executor.shutdown(); + } + } +} diff --git a/src/test/java/umc/cockple/demo/global/config/WebPropertiesTest.java b/src/test/java/umc/cockple/demo/global/config/WebPropertiesTest.java new file mode 100644 index 000000000..e460752a5 --- /dev/null +++ b/src/test/java/umc/cockple/demo/global/config/WebPropertiesTest.java @@ -0,0 +1,73 @@ +package umc.cockple.demo.global.config; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.source.MapConfigurationPropertySource; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * WebProperties 바인딩 검증. + * 도메인 값을 하드코딩에서 설정 바인딩으로 옮긴 뒤에도, 운영과 "동일한" origin 목록/쿠키 도메인이 + * 실제로 그대로 풀리는지(no-op) 배포 없이 결정적으로 확인한다. + */ +class WebPropertiesTest { + + private WebProperties bind(Map props) { + Binder binder = new Binder(new MapConfigurationPropertySource(props)); + return binder.bind("cockple.web", WebProperties.class).get(); + } + + @Test + @DisplayName("application.yml 기본값(콤마 구분)이 운영과 동일한 origin 목록으로 바인딩된다") + void bindsCommaSeparatedOriginsExactlyAsBefore() { + // application.yml의 CORS_ALLOWED_ORIGINS 기본값과 동일한 문자열 + // (WS 편입으로 CORS/WS가 공유. cockple-fe.vercel.app/ 는 WS 기존값 유지용) + String defaultOrigins = + "http://localhost:5173,https://cockple.store,https://www.cockple.store," + + "https://staging.cockple.store,https://cockple-fe.vercel.app,https://cockple-fe.vercel.app/"; + + WebProperties props = bind(Map.of( + "cockple.web.allowed-origins", defaultOrigins, + "cockple.web.cookie-domain", ".cockple.store" + )); + + // 기존 CORS + WS 하드코딩 origin을 모두 포함해야 한다(순수 no-op) + assertThat(props.getAllowedOrigins()).containsExactly( + "http://localhost:5173", + "https://cockple.store", + "https://www.cockple.store", + "https://staging.cockple.store", + "https://cockple-fe.vercel.app", + "https://cockple-fe.vercel.app/" + ); + assertThat(props.getCookieDomain()).isEqualTo(".cockple.store"); + } + + @Test + @DisplayName("cookie-domain이 비면 null/blank로 바인딩되어 host-only 쿠키로 동작한다") + void blankCookieDomainMeansHostOnly() { + WebProperties props = bind(Map.of( + "cockple.web.allowed-origins", "http://localhost:5173", + "cockple.web.cookie-domain", "" + )); + + String cookieDomain = props.getCookieDomain(); + assertThat(cookieDomain == null || cookieDomain.isBlank()).isTrue(); + } + + @Test + @DisplayName("env로 새 도메인을 주입하면 코드 변경 없이 목록이 교체된다") + void envOverrideReplacesOrigins() { + WebProperties props = bind(Map.of( + "cockple.web.allowed-origins", "https://new-domain.com,https://api.new-domain.com" + )); + + assertThat(props.getAllowedOrigins()) + .containsExactly("https://new-domain.com", "https://api.new-domain.com"); + } +} diff --git a/src/test/java/umc/cockple/demo/global/jwt/domain/JwtTokenProviderTest.java b/src/test/java/umc/cockple/demo/global/jwt/domain/JwtTokenProviderTest.java new file mode 100644 index 000000000..8734b1ef9 --- /dev/null +++ b/src/test/java/umc/cockple/demo/global/jwt/domain/JwtTokenProviderTest.java @@ -0,0 +1,55 @@ +package umc.cockple.demo.global.jwt.domain; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import umc.cockple.demo.global.jwt.properties.JwtProperties; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("JwtTokenProvider - 토큰 type claim으로 access/refresh 구분") +class JwtTokenProviderTest { + + private JwtTokenProvider jwtTokenProvider; + + @BeforeEach + void setUp() { + JwtProperties props = new JwtProperties(); + props.setSecret("dGVzdC1zZWNyZXQta2V5LWZvci1pbnRlZ3JhdGlvbi10ZXN0LWxvbmctZW5vdWdoLTI1Ng"); + props.setAccessTokenValidity(900000); + props.setRefreshTokenValidity(1209600000); + jwtTokenProvider = new JwtTokenProvider(props); + jwtTokenProvider.init(); + } + + @Test + @DisplayName("access 토큰은 isAccessToken=true, isRefreshToken=false 이고 ver/subject를 담는다") + void accessTokenIsAccessType() { + String token = jwtTokenProvider.createAccessToken(1L, "nick", 3L); + + assertThat(jwtTokenProvider.getTokenType(token)).isEqualTo("access"); + assertThat(jwtTokenProvider.isAccessToken(token)).isTrue(); + assertThat(jwtTokenProvider.isRefreshToken(token)).isFalse(); + assertThat(jwtTokenProvider.getTokenVersion(token)).isEqualTo(3L); + assertThat(jwtTokenProvider.getUserId(token)).isEqualTo(1L); + } + + @Test + @DisplayName("refresh 토큰은 isRefreshToken=true, isAccessToken=false") + void refreshTokenIsRefreshType() { + String token = jwtTokenProvider.createRefreshToken(1L, "nick", 0L); + + assertThat(jwtTokenProvider.getTokenType(token)).isEqualTo("refresh"); + assertThat(jwtTokenProvider.isRefreshToken(token)).isTrue(); + assertThat(jwtTokenProvider.isAccessToken(token)).isFalse(); + } + + @Test + @DisplayName("dev 토큰은 access 용도로 발급된다") + void devTokenIsAccessType() { + String token = jwtTokenProvider.createDevToken(1L, "nick", 0L); + + assertThat(jwtTokenProvider.isAccessToken(token)).isTrue(); + assertThat(jwtTokenProvider.isRefreshToken(token)).isFalse(); + } +} diff --git a/src/test/java/umc/cockple/demo/global/logging/LogbackSpringConfigurationTest.java b/src/test/java/umc/cockple/demo/global/logging/LogbackSpringConfigurationTest.java new file mode 100644 index 000000000..b4e57f840 --- /dev/null +++ b/src/test/java/umc/cockple/demo/global/logging/LogbackSpringConfigurationTest.java @@ -0,0 +1,253 @@ +package umc.cockple.demo.global.logging; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.AsyncAppender; +import ch.qos.logback.classic.encoder.PatternLayoutEncoder; +import ch.qos.logback.classic.filter.ThresholdFilter; +import ch.qos.logback.classic.joran.JoranConfigurator; +import ch.qos.logback.core.Appender; +import ch.qos.logback.core.OutputStreamAppender; +import ch.qos.logback.core.spi.FilterAttachable; +import ch.qos.logback.core.status.Status; +import ch.qos.logback.core.status.StatusUtil; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.boot.logging.LoggingInitializationContext; +import org.springframework.core.io.ClassPathResource; +import org.springframework.mock.env.MockEnvironment; + +import java.lang.reflect.Constructor; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class LogbackSpringConfigurationTest { + + private static final String WEBSOCKET_LOGGER = "umc.cockple.demo.domain.chat.presentation.websocket"; + private static final String WEBSOCKET_SERVICE_LOGGER = "umc.cockple.demo.domain.chat.service.websocket"; + private static final String SPRING_WEBSOCKET_LOGGER = "org.springframework.web.socket"; + + @TempDir + Path logPath; + + @Test + @DisplayName("prod 프로필에서는 WebSocket 로그가 파일 appender로만 라우팅된다") + void prodProfileRoutesWebSocketLogsToFileOnly() throws Exception { + LoggerContext context = configure("prod"); + + try { + assertThat(appenderNames(context.getLogger(WEBSOCKET_LOGGER))) + .containsExactlyInAnyOrder("ASYNC_WEBSOCKET_FILE", "ASYNC_ERROR_FILE"); + assertThat(context.getLogger(WEBSOCKET_LOGGER).isAdditive()).isFalse(); + assertThat(appenderNames(context.getLogger(WEBSOCKET_SERVICE_LOGGER))) + .containsExactlyInAnyOrder("ASYNC_WEBSOCKET_FILE", "ASYNC_ERROR_FILE"); + assertThat(appenderNames(context.getLogger(SPRING_WEBSOCKET_LOGGER))) + .containsExactlyInAnyOrder("ASYNC_WEBSOCKET_FILE", "ASYNC_ERROR_FILE"); + assertThat(appenderNames(context.getLogger(Logger.ROOT_LOGGER_NAME))) + .containsExactlyInAnyOrder("CONSOLE", "ASYNC_APPLICATION_FILE", "ASYNC_ERROR_FILE"); + assertThat(appenderPattern(context, "CONSOLE")).contains("%clr", "%highlight"); + assertThat(appenderPattern(context, "APPLICATION_FILE")).doesNotContain("%clr", "%highlight"); + assertThat(appenderPattern(context, "WEBSOCKET_FILE")).doesNotContain("%clr", "%highlight"); + assertThat(appenderPattern(context, "ERROR_FILE")).doesNotContain("%clr", "%highlight"); + assertHttpMdcPattern(appenderPattern(context, "CONSOLE")); + assertHttpMdcPattern(appenderPattern(context, "APPLICATION_FILE")); + assertHttpMdcPattern(appenderPattern(context, "WEBSOCKET_FILE")); + assertHttpMdcPattern(appenderPattern(context, "ERROR_FILE")); + assertAsyncAppender(context, "ASYNC_APPLICATION_FILE", "APPLICATION_FILE", 1024, true); + assertAsyncAppender(context, "ASYNC_WEBSOCKET_FILE", "WEBSOCKET_FILE", 2048, true); + assertAsyncAppender(context, "ASYNC_ERROR_FILE", "ERROR_FILE", 512, false); + assertThresholdFilter(context, "ASYNC_ERROR_FILE"); + assertThresholdFilter(context, "ERROR_FILE"); + } finally { + context.stop(); + } + } + + @Test + @DisplayName("local 프로필에서는 WebSocket 로그가 콘솔과 파일 appender에 함께 라우팅된다") + void localProfileRoutesWebSocketLogsToConsoleAndFile() throws Exception { + LoggerContext context = configure("local"); + + try { + assertThat(appenderNames(context.getLogger(WEBSOCKET_LOGGER))) + .containsExactlyInAnyOrder("CONSOLE", "ASYNC_WEBSOCKET_FILE", "ASYNC_ERROR_FILE"); + assertThat(context.getLogger(WEBSOCKET_LOGGER).isAdditive()).isFalse(); + assertThat(appenderNames(context.getLogger(WEBSOCKET_SERVICE_LOGGER))) + .containsExactlyInAnyOrder("CONSOLE", "ASYNC_WEBSOCKET_FILE", "ASYNC_ERROR_FILE"); + assertThat(appenderNames(context.getLogger(Logger.ROOT_LOGGER_NAME))) + .containsExactlyInAnyOrder("CONSOLE", "ASYNC_APPLICATION_FILE", "ASYNC_ERROR_FILE"); + assertThat(appenderPattern(context, "CONSOLE")).contains("%clr", "%highlight"); + assertThat(appenderPattern(context, "APPLICATION_FILE")).doesNotContain("%clr", "%highlight"); + assertThat(appenderPattern(context, "WEBSOCKET_FILE")).doesNotContain("%clr", "%highlight"); + assertThat(appenderPattern(context, "ERROR_FILE")).doesNotContain("%clr", "%highlight"); + assertHttpMdcPattern(appenderPattern(context, "CONSOLE")); + assertHttpMdcPattern(appenderPattern(context, "APPLICATION_FILE")); + assertHttpMdcPattern(appenderPattern(context, "WEBSOCKET_FILE")); + assertHttpMdcPattern(appenderPattern(context, "ERROR_FILE")); + assertAsyncAppender(context, "ASYNC_APPLICATION_FILE", "APPLICATION_FILE", 1024, true); + assertAsyncAppender(context, "ASYNC_WEBSOCKET_FILE", "WEBSOCKET_FILE", 2048, true); + assertAsyncAppender(context, "ASYNC_ERROR_FILE", "ERROR_FILE", 512, false); + assertThresholdFilter(context, "ASYNC_ERROR_FILE"); + assertThresholdFilter(context, "ERROR_FILE"); + } finally { + context.stop(); + } + } + + @Test + @DisplayName("test 프로필에서는 XML 테스트 리포트 보호를 위해 콘솔 색상을 사용하지 않는다") + void testProfileUsesPlainConsolePattern() throws Exception { + LoggerContext context = configure("test"); + + try { + assertThat(appenderNames(context.getLogger(Logger.ROOT_LOGGER_NAME))) + .containsExactlyInAnyOrder("CONSOLE", "ASYNC_APPLICATION_FILE", "ASYNC_ERROR_FILE"); + assertThat(appenderPattern(context, "CONSOLE")).doesNotContain("%clr", "%highlight"); + assertThat(appenderPattern(context, "APPLICATION_FILE")).doesNotContain("%clr", "%highlight"); + assertThat(appenderPattern(context, "ERROR_FILE")).doesNotContain("%clr", "%highlight"); + assertHttpMdcPattern(appenderPattern(context, "CONSOLE")); + assertHttpMdcPattern(appenderPattern(context, "APPLICATION_FILE")); + assertHttpMdcPattern(appenderPattern(context, "ERROR_FILE")); + } finally { + context.stop(); + } + } + + @Test + @DisplayName("명시 런타임 프로필이 아니면 기본 콘솔 패턴은 색상을 사용하지 않는다") + void defaultProfileUsesPlainConsolePattern() throws Exception { + LoggerContext context = configure(); + + try { + assertThat(appenderNames(context.getLogger(Logger.ROOT_LOGGER_NAME))) + .containsExactlyInAnyOrder("CONSOLE", "ASYNC_APPLICATION_FILE", "ASYNC_ERROR_FILE"); + assertThat(appenderPattern(context, "CONSOLE")).doesNotContain("%clr", "%highlight"); + assertHttpMdcPattern(appenderPattern(context, "CONSOLE")); + } finally { + context.stop(); + } + } + + private LoggerContext configure() throws Exception { + LoggerContext context = new LoggerContext(); + context.putProperty("LOG_PATH", logPath.toString()); + + MockEnvironment environment = new MockEnvironment(); + LoggingInitializationContext initializationContext = new LoggingInitializationContext(environment); + configureContext(context, initializationContext); + return context; + } + + private LoggerContext configure(String profile) throws Exception { + LoggerContext context = new LoggerContext(); + context.putProperty("LOG_PATH", logPath.toString()); + + MockEnvironment environment = new MockEnvironment(); + environment.setActiveProfiles(profile); + LoggingInitializationContext initializationContext = new LoggingInitializationContext(environment); + configureContext(context, initializationContext); + return context; + } + + private void configureContext( + LoggerContext context, + LoggingInitializationContext initializationContext + ) throws Exception { + Constructor constructor = Class.forName("org.springframework.boot.logging.logback.SpringBootJoranConfigurator") + .getDeclaredConstructor(LoggingInitializationContext.class); + constructor.setAccessible(true); + JoranConfigurator configurator = (JoranConfigurator) constructor.newInstance(initializationContext); + configurator.setContext(context); + configurator.doConfigure(new ClassPathResource("logback-spring.xml").getFile()); + + StatusUtil statusUtil = new StatusUtil(context); + assertThat(statusUtil.getHighestLevel(0)).isLessThan(Status.ERROR); + } + + private List appenderNames(Logger logger) { + List names = new ArrayList<>(); + Iterator> iterator = logger.iteratorForAppenders(); + while (iterator.hasNext()) { + Appender appender = iterator.next(); + names.add(appender.getName()); + } + return names; + } + + private String appenderPattern(LoggerContext context, String appenderName) { + Appender appender = findAppender(context, appenderName); + assertThat(appender).isInstanceOf(OutputStreamAppender.class); + OutputStreamAppender outputStreamAppender = (OutputStreamAppender) appender; + assertThat(outputStreamAppender.getEncoder()).isInstanceOf(PatternLayoutEncoder.class); + PatternLayoutEncoder encoder = (PatternLayoutEncoder) outputStreamAppender.getEncoder(); + return encoder.getPattern(); + } + + private void assertHttpMdcPattern(String pattern) { + assertThat(pattern).contains("%X{method:-}", "%X{uri:-}", "%X{clientIp:-}"); + } + + private void assertAsyncAppender( + LoggerContext context, + String asyncAppenderName, + String nestedAppenderName, + int queueSize, + boolean neverBlock + ) { + Appender appender = findAppender(context, asyncAppenderName); + assertThat(appender).isInstanceOf(AsyncAppender.class); + AsyncAppender asyncAppender = (AsyncAppender) appender; + assertThat(asyncAppender.getQueueSize()).isEqualTo(queueSize); + assertThat(asyncAppender.getDiscardingThreshold()).isZero(); + assertThat(asyncAppender.isNeverBlock()).isEqualTo(neverBlock); + assertThat(asyncAppender.isIncludeCallerData()).isFalse(); + assertThat(asyncAppender.getAppender(nestedAppenderName)).isNotNull(); + } + + @SuppressWarnings("unchecked") + private void assertThresholdFilter(LoggerContext context, String appenderName) { + Appender appender = findAppender(context, appenderName); + assertThat(appender).isInstanceOf(FilterAttachable.class); + FilterAttachable filterAttachable = + (FilterAttachable) appender; + assertThat(filterAttachable.getCopyOfAttachedFiltersList()) + .hasSize(1) + .allMatch(ThresholdFilter.class::isInstance); + } + + private Appender findAppender(LoggerContext context, String appenderName) { + List loggers = List.of( + context.getLogger(Logger.ROOT_LOGGER_NAME), + context.getLogger(WEBSOCKET_LOGGER), + context.getLogger(WEBSOCKET_SERVICE_LOGGER), + context.getLogger(SPRING_WEBSOCKET_LOGGER) + ); + + for (Logger logger : loggers) { + Appender directAppender = logger.getAppender(appenderName); + if (directAppender != null) { + return directAppender; + } + } + + for (Logger logger : loggers) { + Iterator> iterator = logger.iteratorForAppenders(); + while (iterator.hasNext()) { + Appender appender = iterator.next(); + if (appender instanceof AsyncAppender asyncAppender) { + Appender nestedAppender = asyncAppender.getAppender(appenderName); + if (nestedAppender != null) { + return nestedAppender; + } + } + } + } + + return null; + } +} diff --git a/src/test/java/umc/cockple/demo/global/logging/MdcLoggingFilterTest.java b/src/test/java/umc/cockple/demo/global/logging/MdcLoggingFilterTest.java new file mode 100644 index 000000000..81ea6a1c4 --- /dev/null +++ b/src/test/java/umc/cockple/demo/global/logging/MdcLoggingFilterTest.java @@ -0,0 +1,75 @@ +package umc.cockple.demo.global.logging; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +class MdcLoggingFilterTest { + + private final MdcLoggingFilter filter = new MdcLoggingFilter(); + + @AfterEach + void tearDown() { + MDC.clear(); + } + + @Test + @DisplayName("X-Request-Id가 있으면 요청 MDC에 재사용하고 종료 후 정리한다") + void reuseRequestIdAndClearMdc() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/exercises"); + request.addHeader("X-Request-Id", "request-123"); + request.addHeader("X-Forwarded-For", "203.0.113.10, 10.0.0.1"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + AtomicReference requestId = new AtomicReference<>(); + AtomicReference method = new AtomicReference<>(); + AtomicReference uri = new AtomicReference<>(); + AtomicReference clientIp = new AtomicReference<>(); + + filter.doFilter(request, response, (servletRequest, servletResponse) -> { + requestId.set(MDC.get(MdcLoggingFilter.REQUEST_ID)); + method.set(MDC.get(MdcLoggingFilter.METHOD)); + uri.set(MDC.get(MdcLoggingFilter.URI)); + clientIp.set(MDC.get(MdcLoggingFilter.CLIENT_IP)); + }); + + assertThat(requestId.get()).isEqualTo("request-123"); + assertThat(method.get()).isEqualTo("POST"); + assertThat(uri.get()).isEqualTo("/api/exercises"); + assertThat(clientIp.get()).isEqualTo("203.0.113.10"); + assertThat(MDC.get(MdcLoggingFilter.REQUEST_ID)).isNull(); + assertThat(MDC.get(MdcLoggingFilter.METHOD)).isNull(); + assertThat(MDC.get(MdcLoggingFilter.URI)).isNull(); + assertThat(MDC.get(MdcLoggingFilter.CLIENT_IP)).isNull(); + } + + @Test + @DisplayName("X-Request-Id가 없으면 UUID를 생성한다") + void generateRequestIdWhenHeaderIsMissing() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/members/me"); + request.setRemoteAddr("127.0.0.1"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + AtomicReference requestId = new AtomicReference<>(); + AtomicReference clientIp = new AtomicReference<>(); + + filter.doFilter(request, response, (servletRequest, servletResponse) -> { + requestId.set(MDC.get(MdcLoggingFilter.REQUEST_ID)); + clientIp.set(MDC.get(MdcLoggingFilter.CLIENT_IP)); + }); + + assertThatCode(() -> UUID.fromString(requestId.get())).doesNotThrowAnyException(); + assertThat(clientIp.get()).isEqualTo("127.0.0.1"); + assertThat(MDC.get(MdcLoggingFilter.REQUEST_ID)).isNull(); + assertThat(MDC.get(MdcLoggingFilter.CLIENT_IP)).isNull(); + } +} diff --git a/src/test/java/umc/cockple/demo/global/logging/MdcTaskDecoratorTest.java b/src/test/java/umc/cockple/demo/global/logging/MdcTaskDecoratorTest.java new file mode 100644 index 000000000..93172c94a --- /dev/null +++ b/src/test/java/umc/cockple/demo/global/logging/MdcTaskDecoratorTest.java @@ -0,0 +1,63 @@ +package umc.cockple.demo.global.logging; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; +import umc.cockple.demo.global.security.filter.JwtAuthenticationFilter; + +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; + +class MdcTaskDecoratorTest { + + private final MdcTaskDecorator decorator = new MdcTaskDecorator(); + + @AfterEach + void tearDown() { + MDC.clear(); + } + + @Test + @DisplayName("decorate 시점의 MDC를 작업 실행 동안 전파하고 이전 MDC를 복구한다") + void copyParentMdcAndRestorePreviousMdc() { + MDC.put(MdcLoggingFilter.REQUEST_ID, "request-1"); + MDC.put(JwtAuthenticationFilter.MEMBER_ID, "7"); + + AtomicReference requestIdInTask = new AtomicReference<>(); + AtomicReference memberIdInTask = new AtomicReference<>(); + Runnable decorated = decorator.decorate(() -> { + requestIdInTask.set(MDC.get(MdcLoggingFilter.REQUEST_ID)); + memberIdInTask.set(MDC.get(JwtAuthenticationFilter.MEMBER_ID)); + }); + + MDC.clear(); + MDC.put(MdcLoggingFilter.REQUEST_ID, "worker-before"); + + decorated.run(); + + assertThat(requestIdInTask.get()).isEqualTo("request-1"); + assertThat(memberIdInTask.get()).isEqualTo("7"); + assertThat(MDC.get(MdcLoggingFilter.REQUEST_ID)).isEqualTo("worker-before"); + assertThat(MDC.get(JwtAuthenticationFilter.MEMBER_ID)).isNull(); + } + + @Test + @DisplayName("decorate 시점에 MDC가 없으면 작업 실행 동안 기존 worker MDC를 비운 뒤 복구한다") + void clearWorkerMdcWhenParentMdcIsEmptyAndRestoreAfterwards() { + MDC.clear(); + + AtomicReference requestIdInTask = new AtomicReference<>(); + Runnable decorated = decorator.decorate(() -> + requestIdInTask.set(MDC.get(MdcLoggingFilter.REQUEST_ID)) + ); + + MDC.put(MdcLoggingFilter.REQUEST_ID, "worker-before"); + + decorated.run(); + + assertThat(requestIdInTask.get()).isNull(); + assertThat(MDC.get(MdcLoggingFilter.REQUEST_ID)).isEqualTo("worker-before"); + } +} diff --git a/src/test/java/umc/cockple/demo/global/oauth2/service/KakaoOauthServiceTest.java b/src/test/java/umc/cockple/demo/global/oauth2/service/KakaoOauthServiceTest.java new file mode 100644 index 000000000..ac743a3ef --- /dev/null +++ b/src/test/java/umc/cockple/demo/global/oauth2/service/KakaoOauthServiceTest.java @@ -0,0 +1,179 @@ +package umc.cockple.demo.global.oauth2.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.enums.MemberStatus; +import umc.cockple.demo.domain.member.exception.MemberErrorCode; +import umc.cockple.demo.domain.member.exception.MemberException; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.global.auth.RefreshTokenRepository; +import umc.cockple.demo.global.auth.TokenVersionRepository; +import umc.cockple.demo.global.jwt.domain.JwtTokenProvider; +import umc.cockple.demo.global.jwt.domain.TokenRefreshResponse; +import umc.cockple.demo.global.oauth2.domain.KakaoClient; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("KakaoOauthService.validateMember - 리프레시 토큰 재사용 탐지") +class KakaoOauthServiceTest { + + @InjectMocks + private KakaoOauthService kakaoOauthService; + + @Mock private KakaoClient kakaoClient; + @Mock private MemberRepository memberRepository; + @Mock private JwtTokenProvider jwtTokenProvider; + @Mock private RefreshTokenRepository refreshTokenRepository; + @Mock private TokenVersionRepository tokenVersionRepository; + + private static final String RT = "refresh.token.value"; + private static final Long MEMBER_ID = 1L; + + @Nested + @DisplayName("정상 회전") + class NormalRotation { + + @Test + @DisplayName("활성 토큰이면 소비 이력을 남기고 새 토큰을 재발급한다") + void 정상_회전() { + // given + Member member = mock(Member.class); + given(member.getId()).willReturn(MEMBER_ID); + given(member.getNickname()).willReturn("와나"); + given(member.getIsActive()).willReturn(MemberStatus.ACTIVE); + + given(refreshTokenRepository.consumeAndMark(RT)).willReturn(Optional.of(MEMBER_ID)); + given(memberRepository.findById(MEMBER_ID)).willReturn(Optional.of(member)); + given(tokenVersionRepository.getVersion(MEMBER_ID)).willReturn(0L); + given(jwtTokenProvider.getTokenVersion(RT)).willReturn(0L); + given(jwtTokenProvider.createAccessToken(eq(MEMBER_ID), any(), eq(0L))).willReturn("newAccess"); + given(jwtTokenProvider.createRefreshToken(eq(MEMBER_ID), any(), eq(0L))).willReturn("newRefresh"); + + // when + TokenRefreshResponse response = kakaoOauthService.validateMember(RT); + + // then + assertThat(response.accessToken()).isEqualTo("newAccess"); + assertThat(response.refreshToken()).isEqualTo("newRefresh"); + verify(refreshTokenRepository).consumeAndMark(RT); + verify(refreshTokenRepository).save("newRefresh", MEMBER_ID); + verify(tokenVersionRepository, never()).increment(anyLong()); + } + } + + @Nested + @DisplayName("활성 저장소에 없는 토큰") + class TokenNotInStore { + + @Test + @DisplayName("서명 유효 + grace 경과 → 재사용으로 판정하고 전체 토큰을 무효화한다") + void 재사용_확정() { + // given + given(refreshTokenRepository.consumeAndMark(RT)).willReturn(Optional.empty()); + given(jwtTokenProvider.validateToken(RT)).willReturn(true); + given(jwtTokenProvider.isRefreshToken(RT)).willReturn(true); + given(refreshTokenRepository.isRecentlyConsumed(RT)).willReturn(false); + given(jwtTokenProvider.getUserId(RT)).willReturn(MEMBER_ID); + + // when & then + assertThatThrownBy(() -> kakaoOauthService.validateMember(RT)) + .isInstanceOf(MemberException.class) + .hasFieldOrPropertyWithValue("code", MemberErrorCode.INVALID_REFRESH_TOKEN); + + verify(tokenVersionRepository).increment(MEMBER_ID); + } + + @Test + @DisplayName("grace window 이내 소비 이력이 있으면(동시요청/재시도) 무효화하지 않는다") + void grace_이내_재시도() { + // given + given(refreshTokenRepository.consumeAndMark(RT)).willReturn(Optional.empty()); + given(jwtTokenProvider.validateToken(RT)).willReturn(true); + given(jwtTokenProvider.isRefreshToken(RT)).willReturn(true); + given(refreshTokenRepository.isRecentlyConsumed(RT)).willReturn(true); + + // when & then + assertThatThrownBy(() -> kakaoOauthService.validateMember(RT)) + .isInstanceOf(MemberException.class) + .hasFieldOrPropertyWithValue("code", MemberErrorCode.INVALID_REFRESH_TOKEN); + + verify(tokenVersionRepository, never()).increment(anyLong()); + } + + @Test + @DisplayName("서명 무효/만료 토큰이면 무효화하지 않고 일반 거부한다") + void 만료_또는_무효_토큰() { + // given + given(refreshTokenRepository.consumeAndMark(RT)).willReturn(Optional.empty()); + given(jwtTokenProvider.validateToken(RT)).willReturn(false); + + // when & then + assertThatThrownBy(() -> kakaoOauthService.validateMember(RT)) + .isInstanceOf(MemberException.class) + .hasFieldOrPropertyWithValue("code", MemberErrorCode.INVALID_REFRESH_TOKEN); + + verify(tokenVersionRepository, never()).increment(anyLong()); + } + + @Test + @DisplayName("access 토큰을 재발급에 오용하면(refresh 타입 아님) 무효화 없이 거부한다") + void access_토큰을_refresh로_오용() { + // given - 활성 저장소에 없음(access는 refresh 저장소에 저장된 적 없음) + given(refreshTokenRepository.consumeAndMark(RT)).willReturn(Optional.empty()); + given(jwtTokenProvider.validateToken(RT)).willReturn(true); + given(jwtTokenProvider.isRefreshToken(RT)).willReturn(false); + + // when & then + assertThatThrownBy(() -> kakaoOauthService.validateMember(RT)) + .isInstanceOf(MemberException.class) + .hasFieldOrPropertyWithValue("code", MemberErrorCode.INVALID_REFRESH_TOKEN); + + // 탈취가 아니므로 전체 무효화하지 않는다 + verify(tokenVersionRepository, never()).increment(anyLong()); + } + } + + @Nested + @DisplayName("토큰 버전 검증") + class TokenVersionCheck { + + @Test + @DisplayName("활성 토큰이어도 tokenVersion이 올라가 있으면(탈퇴/무효화된 예전 RT) 재발급을 거부한다") + void 버전_불일치_거부() { + // given + Member member = mock(Member.class); + given(member.getId()).willReturn(MEMBER_ID); + given(member.getIsActive()).willReturn(MemberStatus.ACTIVE); + + given(refreshTokenRepository.consumeAndMark(RT)).willReturn(Optional.of(MEMBER_ID)); + given(memberRepository.findById(MEMBER_ID)).willReturn(Optional.of(member)); + given(tokenVersionRepository.getVersion(MEMBER_ID)).willReturn(1L); // 현재 버전(무효화 후) + given(jwtTokenProvider.getTokenVersion(RT)).willReturn(0L); // 예전에 발급된 토큰 + + // when & then + assertThatThrownBy(() -> kakaoOauthService.validateMember(RT)) + .isInstanceOf(MemberException.class) + .hasFieldOrPropertyWithValue("code", MemberErrorCode.INVALID_REFRESH_TOKEN); + + // 새 토큰을 발급/저장하지 않는다 + verify(refreshTokenRepository, never()).save(any(), anyLong()); + } + } +} diff --git a/src/test/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilterMdcTest.java b/src/test/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilterMdcTest.java new file mode 100644 index 000000000..1d8711922 --- /dev/null +++ b/src/test/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilterMdcTest.java @@ -0,0 +1,117 @@ +package umc.cockple.demo.global.security.filter; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.slf4j.MDC; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import umc.cockple.demo.global.exception.RestAuthenticationEntryPoint; +import umc.cockple.demo.global.jwt.domain.JwtTokenProvider; + +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +@ExtendWith(MockitoExtension.class) +class JwtAuthenticationFilterMdcTest { + + @Mock + private JwtTokenProvider jwtTokenProvider; + + @Mock + private RestAuthenticationEntryPoint restEntryPoint; + + @AfterEach + void tearDown() { + MDC.clear(); + } + + @Test + @DisplayName("인증 성공 시 요청 처리 동안 memberId를 MDC에 넣고 종료 후 정리한다") + void putMemberIdDuringAuthenticatedRequestAndClearAfterwards() throws Exception { + JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, restEntryPoint); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/members/me"); + request.addHeader("Authorization", "Bearer token"); + MockHttpServletResponse response = new MockHttpServletResponse(); + AtomicReference memberIdInChain = new AtomicReference<>(); + + given(jwtTokenProvider.validateToken("token")).willReturn(true); + given(jwtTokenProvider.isAccessToken("token")).willReturn(true); + given(jwtTokenProvider.getUserId("token")).willReturn(7L); + given(jwtTokenProvider.getAuthentication("token")) + .willReturn(new UsernamePasswordAuthenticationToken("member7", "")); + + filter.doFilter(request, response, (servletRequest, servletResponse) -> + memberIdInChain.set(MDC.get(JwtAuthenticationFilter.MEMBER_ID)) + ); + + assertThat(memberIdInChain.get()).isEqualTo("7"); + assertThat(MDC.get(JwtAuthenticationFilter.MEMBER_ID)).isNull(); + } + + @Test + @DisplayName("토큰이 없는 요청은 이전 memberId MDC를 제거한 상태로 통과시키고 종료 후에도 정리한다") + void clearStaleMemberIdWhenRequestHasNoToken() throws Exception { + JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, restEntryPoint); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/public"); + MockHttpServletResponse response = new MockHttpServletResponse(); + AtomicReference memberIdInChain = new AtomicReference<>(); + MDC.put(JwtAuthenticationFilter.MEMBER_ID, "stale-member"); + + filter.doFilter(request, response, (servletRequest, servletResponse) -> + memberIdInChain.set(MDC.get(JwtAuthenticationFilter.MEMBER_ID)) + ); + + assertThat(memberIdInChain.get()).isNull(); + assertThat(MDC.get(JwtAuthenticationFilter.MEMBER_ID)).isNull(); + verifyNoInteractions(jwtTokenProvider, restEntryPoint); + } + + @Test + @DisplayName("토큰이 없는 요청의 downstream 예외는 인증 실패로 변환하지 않고 전파한다") + void propagateDownstreamExceptionWhenRequestHasNoToken() { + JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, restEntryPoint); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/public"); + MockHttpServletResponse response = new MockHttpServletResponse(); + MDC.put(JwtAuthenticationFilter.MEMBER_ID, "stale-member"); + + assertThatThrownBy(() -> filter.doFilter(request, response, (servletRequest, servletResponse) -> { + throw new IllegalStateException("downstream failure"); + })) + .isInstanceOf(IllegalStateException.class) + .hasMessage("downstream failure"); + + assertThat(MDC.get(JwtAuthenticationFilter.MEMBER_ID)).isNull(); + verifyNoInteractions(jwtTokenProvider, restEntryPoint); + } + + @Test + @DisplayName("refresh 토큰으로 일반 API에 접근하면 401로 거부하고 체인을 진행하지 않는다") + void rejectRefreshTokenOnApiRequest() throws Exception { + JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, restEntryPoint); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/members/me"); + request.addHeader("Authorization", "Bearer refresh"); + MockHttpServletResponse response = new MockHttpServletResponse(); + AtomicBoolean chainCalled = new AtomicBoolean(false); + + given(jwtTokenProvider.validateToken("refresh")).willReturn(true); + given(jwtTokenProvider.isAccessToken("refresh")).willReturn(false); // refresh 타입 + + filter.doFilter(request, response, (req, res) -> chainCalled.set(true)); + + assertThat(chainCalled).isFalse(); + assertThat(MDC.get(JwtAuthenticationFilter.MEMBER_ID)).isNull(); + verify(restEntryPoint).commence(any(), any(), any()); + } +} diff --git a/src/test/java/umc/cockple/demo/support/QueryCountAssert.java b/src/test/java/umc/cockple/demo/support/QueryCountAssert.java new file mode 100644 index 000000000..ea2ea6c18 --- /dev/null +++ b/src/test/java/umc/cockple/demo/support/QueryCountAssert.java @@ -0,0 +1,49 @@ +package umc.cockple.demo.support; + +import jakarta.persistence.EntityManager; +import org.hibernate.SessionFactory; +import org.hibernate.stat.Statistics; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 특정 동작이 실행하는 SQL 쿼리 수를 단언하는 테스트 헬퍼. + * + * Hibernate의 prepared statement 카운터를 이용해 "이 호출은 SQL이 정확히 N번 나가야 한다"를 박제한다. + * N+1이 발생하면 카운트가 늘어나 테스트가 실패하므로, N+1 회귀를 CI에서 자동으로 잡아낸다. + * + * 1차 캐시를 비워 {@code action} 안의 조회가 실제 DB를 때리게 한다. (캐시 hit으로 인한 과소 집계 방지 → 운영의 콜드 캐시 상황에 맞춤) + * + * 측정 전 clear()로 영속성 컨텍스트가 비워지므로, 이 메서드 호출 이후 픽스처 엔티티는 detach 상태가 된다. 호출 뒤 지연 로딩 접근 시 + * LazyInitializationException이 날 수 있으니, 필요한 값은 호출 전에 읽거나 재조회한다. + */ +public final class QueryCountAssert { + + private QueryCountAssert() { + } + + public static void assertQueryCount(EntityManager em, long expected, Runnable action) { + Statistics statistics = em.getEntityManagerFactory() + .unwrap(SessionFactory.class) + .getStatistics(); + + // 측정 전: 1차 캐시를 비워 action의 조회가 실제 DB를 때리도록 한다. + if (em.isJoinedToTransaction()) { + em.clear(); + } + + statistics.clear(); + action.run(); + + // 측정 후: action이 유발한 쓰기 지연 SQL을 카운트 읽기 전에 강제로 내보낸다. + if (em.isJoinedToTransaction()) { + em.flush(); + } + + long actual = statistics.getPrepareStatementCount(); + + assertThat(actual) + .as("실행된 SQL 쿼리 수가 기대치와 다릅니다 (N+1 가능성)") + .isEqualTo(expected); + } +} diff --git a/src/test/java/umc/cockple/demo/support/QueryCountAssertSmokeTest.java b/src/test/java/umc/cockple/demo/support/QueryCountAssertSmokeTest.java new file mode 100644 index 000000000..818c9106b --- /dev/null +++ b/src/test/java/umc/cockple/demo/support/QueryCountAssertSmokeTest.java @@ -0,0 +1,48 @@ +package umc.cockple.demo.support; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +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 umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.support.fixture.MemberFixture; + +import static umc.cockple.demo.support.QueryCountAssert.assertQueryCount; + +/** + * 헬퍼가 SQL 쿼리 수를 올바르게 세는지 검증하는 스모크 테스트. + * (인프라 자체의 동작 보증용 — 도메인별 N+1 테스트는 각 도메인 PR에서 추가) + */ +@DisplayName("QueryCountAssert 인프라 스모크 테스트") +class QueryCountAssertSmokeTest extends IntegrationTestBase { + + @Autowired + MemberRepository memberRepository; + + @PersistenceContext + EntityManager em; + + private Long savedMemberId; + + @AfterEach + void tearDown() { + memberRepository.deleteAll(); + } + + @Test + @DisplayName("회원 단건 조회(findById)는 정확히 1개의 SQL을 실행한다") + void findById_executesExactlyOneQuery() { + // given - 별도 트랜잭션에서 커밋 (이후 조회 시 1차 캐시에 남지 않음) + Member saved = memberRepository.save( + MemberFixture.createMember("스모크 유저", Gender.MALE, Level.A, 9001L)); + savedMemberId = saved.getId(); + + // when & then - findById 한 번 = SELECT 한 번 + assertQueryCount(em, 1, () -> memberRepository.findById(savedMemberId)); + } +} diff --git a/src/test/resources/application-integrationtest.yml b/src/test/resources/application-integrationtest.yml index bf6c2af19..e75889e10 100644 --- a/src/test/resources/application-integrationtest.yml +++ b/src/test/resources/application-integrationtest.yml @@ -20,6 +20,8 @@ spring: dialect: org.hibernate.dialect.MySQL8Dialect show_sql: true format_sql: true + generate_statistics: true # 쿼리 카운트 테스트(N+1 회귀 방지)용. 테스트 프로파일에만 적용 + default_batch_fetch_size: 1000 # 운영(application.yml)과 동일하게 정합 — 테스트가 운영의 배치 페치 동작을 반영 cache: type: redis @@ -31,6 +33,9 @@ cockple: object-storage-delete-outbox: scheduler: enabled: false + web: + allowed-origins: http://localhost:5173 + cookie-domain: "" kakao: client-id: test-client-id @@ -45,6 +50,7 @@ jwt: secret: dGVzdC1zZWNyZXQta2V5LWZvci1pbnRlZ3JhdGlvbi10ZXN0LWxvbmctZW5vdWdoLTI1Ng access-token-validity: 3600000 refresh-token-validity: 1209600000 + refresh-token-reuse-grace: 30000 logging: level: diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index e055d686f..3741cc941 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -30,3 +30,6 @@ cockple: object-storage-delete-outbox: scheduler: enabled: false + web: + allowed-origins: http://localhost:5173 + cookie-domain: "" diff --git a/terraform/storage.tf b/terraform/storage.tf index 333b02378..dd0e9bf75 100644 --- a/terraform/storage.tf +++ b/terraform/storage.tf @@ -16,7 +16,7 @@ resource "google_storage_bucket" "cockple_assets" { uniform_bucket_level_access = true cors { - origin = ["https://cockple.store", "https://staging.cockple.store"] + origin = ["https://${var.root_domain}", "https://staging.${var.root_domain}"] method = ["GET", "PUT", "POST", "DELETE"] response_header = ["Content-Type"] max_age_seconds = 3600 diff --git a/terraform/variables.tf b/terraform/variables.tf index 1d549e13f..95970edc5 100644 --- a/terraform/variables.tf +++ b/terraform/variables.tf @@ -16,10 +16,16 @@ variable "cloudflare_api_token" { } variable "cloudflare_zone_id" { - description = "cockple.store Cloudflare Zone ID" + description = "루트 도메인(root_domain)의 Cloudflare Zone ID" type = string } +variable "root_domain" { + description = "서비스 루트 도메인. 도메인 변경 시 이 값(+ cloudflare_zone_id)만 교체." + type = string + default = "cockple.store" +} + variable "ssh_public_key" { description = "인스턴스 접속용 SSH 공개키" type = string