diff --git "a/.github/ISSUE_TEMPLATE/\352\270\260\353\212\245-\352\265\254\355\230\204.md" "b/.github/ISSUE_TEMPLATE/\352\270\260\353\212\245-\352\265\254\355\230\204.md"
index 8b6df1b8f..8d7fa8bdd 100644
--- "a/.github/ISSUE_TEMPLATE/\352\270\260\353\212\245-\352\265\254\355\230\204.md"
+++ "b/.github/ISSUE_TEMPLATE/\352\270\260\353\212\245-\352\265\254\355\230\204.md"
@@ -1,7 +1,7 @@
---
name: 기능 구현
about: '기능 설명 및 구현 '
-title: "[FEAT]"
+title: "[FEAT] "
labels: "\U0001F6E0️ FEAT"
assignees: ''
diff --git "a/.github/ISSUE_TEMPLATE/\352\270\260\353\212\245-\354\210\230\354\240\225.md" "b/.github/ISSUE_TEMPLATE/\352\270\260\353\212\245-\354\210\230\354\240\225.md"
index 63fc142d0..9756738e8 100644
--- "a/.github/ISSUE_TEMPLATE/\352\270\260\353\212\245-\354\210\230\354\240\225.md"
+++ "b/.github/ISSUE_TEMPLATE/\352\270\260\353\212\245-\354\210\230\354\240\225.md"
@@ -1,7 +1,7 @@
---
name: 기능 수정
about: 리팩토링 목적이 아닌 기능 수정
-title: "[FIX} "
+title: "[FIX] "
labels: "\U0001F527 FIX"
assignees: ''
diff --git "a/.github/ISSUE_TEMPLATE/\352\270\260\355\203\200-\354\210\230\354\240\225.md" "b/.github/ISSUE_TEMPLATE/\352\270\260\355\203\200-\354\210\230\354\240\225.md"
index 2174d7f49..5f9b0f0ac 100644
--- "a/.github/ISSUE_TEMPLATE/\352\270\260\355\203\200-\354\210\230\354\240\225.md"
+++ "b/.github/ISSUE_TEMPLATE/\352\270\260\355\203\200-\354\210\230\354\240\225.md"
@@ -1,7 +1,7 @@
---
name: 기타 수정
about: '작은 부분 수정 '
-title: "[CHORE]"
+title: "[CHORE] "
labels: "\U0001F3B5 CHORE"
assignees: ''
diff --git "a/.github/ISSUE_TEMPLATE/\353\254\270\354\204\234-\354\236\221\354\227\205-.md" "b/.github/ISSUE_TEMPLATE/\353\254\270\354\204\234-\354\236\221\354\227\205-.md"
index 5a0eeeee4..b8622ce41 100644
--- "a/.github/ISSUE_TEMPLATE/\353\254\270\354\204\234-\354\236\221\354\227\205-.md"
+++ "b/.github/ISSUE_TEMPLATE/\353\254\270\354\204\234-\354\236\221\354\227\205-.md"
@@ -1,7 +1,7 @@
---
name: '문서 작업 '
about: 문서작업 내용
-title: ''
+title: '[Docs] '
labels: "\U0001F4DC DOC"
assignees: ''
diff --git "a/.github/ISSUE_TEMPLATE/\354\230\244\353\245\230\354\210\230\354\240\225.md" "b/.github/ISSUE_TEMPLATE/\354\230\244\353\245\230\354\210\230\354\240\225.md"
index 7fca801cc..1c5c4a404 100644
--- "a/.github/ISSUE_TEMPLATE/\354\230\244\353\245\230\354\210\230\354\240\225.md"
+++ "b/.github/ISSUE_TEMPLATE/\354\230\244\353\245\230\354\210\230\354\240\225.md"
@@ -1,7 +1,7 @@
---
name: 오류수정
about: '오류 설명 및 수정 '
-title: "[DEBUG]"
+title: "[DEBUG] "
labels: "\U0001F577️ BUG"
assignees: ''
diff --git "a/.github/ISSUE_TEMPLATE/\355\205\214\354\212\244\355\212\270-\354\236\221\354\204\261.md" "b/.github/ISSUE_TEMPLATE/\355\205\214\354\212\244\355\212\270-\354\236\221\354\204\261.md"
new file mode 100644
index 000000000..5cf57e0eb
--- /dev/null
+++ "b/.github/ISSUE_TEMPLATE/\355\205\214\354\212\244\355\212\270-\354\236\221\354\204\261.md"
@@ -0,0 +1,12 @@
+---
+name: '테스트 작성'
+about: 테스트 코드 작성
+title: '[Test] '
+labels: "🧪 TEST"
+assignees: ''
+
+---
+
+## 🔑 테스트 내용
+
+
diff --git a/.github/workflows/AGENTS.md b/.github/workflows/AGENTS.md
new file mode 100644
index 000000000..ccd7b9793
--- /dev/null
+++ b/.github/workflows/AGENTS.md
@@ -0,0 +1,28 @@
+# WORKFLOWS GUIDE
+
+Apply root `AGENTS.md` first. This file only applies to `.github/workflows/*` edits.
+
+## OVERVIEW
+This directory drives CI on pull requests and CD on pushes to `develop` and `main`.
+
+## WHERE TO LOOK
+| Task | Location | Notes |
+|------|----------|-------|
+| PR build/test | `ci.yml` | writes `application.yml`, creates Firebase key, runs `./gradlew build` |
+| Image build + deploy | `cd.yml` | builds on push, tags by branch, then copies compose/nginx/scripts to server |
+
+## CONVENTIONS
+- `develop` and `main` are the only workflow branches here.
+- `ci.yml` chooses `APPLICATION` vs `APPLICATION_STAGING` by `github.base_ref`.
+- `cd.yml` chooses Docker tag `latest` for `main`, `staging` otherwise.
+- Deploy step assumes `/home/ubuntu/cockple` and hands off to `scripts/deploy.sh`.
+
+## ANTI-PATTERNS
+- Do not hardcode secrets into workflow YAML.
+- Do not change copied deploy assets in `cd.yml` without matching script/compose expectations.
+- Do not make `main` and `develop` diverge silently; branch-specific behavior is intentionally small and explicit.
+
+## COMMANDS
+```bash
+./gradlew.bat build
+```
diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml
index a25b79bbc..9d3892c74 100644
--- a/.github/workflows/cd.yml
+++ b/.github/workflows/cd.yml
@@ -27,6 +27,7 @@ jobs:
else
echo "DOCKER_TAG=staging" >> $GITHUB_OUTPUT
fi
+
- name: Build with Gradle
run: |
@@ -71,7 +72,7 @@ jobs:
envs: >-
DB_PASSWORD,GCS_BUCKET,
KAKAO_CLIENT_ID,KAKAO_CLIENT_SECRET,KAKAO_REDIRECT_URI_PROD,KAKAO_REDIRECT_URI_STAGING,KAKAO_ADMIN_KEY,
- JWT_SECRET_KEY
+ JWT_SECRET_KEY,FIREBASE_SERVICE_ACCOUNT_KEY
script: |
chmod +x /home/ubuntu/cockple/scripts/deploy.sh
bash /home/ubuntu/cockple/scripts/deploy.sh \
@@ -86,3 +87,4 @@ jobs:
KAKAO_REDIRECT_URI_STAGING: ${{ secrets.KAKAO_REDIRECT_URI_STAGING }}
KAKAO_ADMIN_KEY: ${{ secrets.KAKAO_ADMIN_KEY }}
JWT_SECRET_KEY: ${{ secrets.JWT_SECRET_KEY }}
+ FIREBASE_SERVICE_ACCOUNT_KEY: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_KEY }}
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8f0cb4c48..e271fa7f6 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -31,6 +31,11 @@ jobs:
echo "${{ secrets.APPLICATION_STAGING }}" > src/main/resources/application.yml
fi
+ - name: Create Firebase Key File
+ run: |
+ mkdir -p src/main/resources/firebase
+ echo '${{ secrets.FIREBASE_SERVICE_ACCOUNT_KEY }}' > src/main/resources/firebase/cockple-1a83e-firebase-adminsdk-fbsvc-212ce01565.json
+
- name: Grant execute permission for Gradle
run: chmod +x gradlew
diff --git a/.gitignore b/.gitignore
index 7b042e06d..c03094428 100644
--- a/.gitignore
+++ b/.gitignore
@@ -47,4 +47,11 @@ application-dev.yml
terraform/.terraform/
terraform/terraform.tfstate
terraform/terraform.tfstate.backup
-terraform/terraform.tfvars
\ No newline at end of file
+terraform/terraform.tfvars
+
+### firebase ###
+src/main/resources/firebase/*.json
+
+### Claude / OMC ###
+.claude/
+.omc/
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 000000000..833ffa033
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,84 @@
+# PROJECT KNOWLEDGE BASE
+
+**Generated:** 2026-03-23 20:04 KST
+**Commit:** 5af1ffe6
+**Branch:** develop
+
+## OVERVIEW
+Cockple_BE is a single-module Spring Boot 3.5.9 backend for the Cockple badminton platform. Core stack: Java 17, JPA + QueryDSL, MySQL, Redis, JWT/Kakao OAuth, WebSocket chat, Firebase FCM, Flyway, Docker Compose, and Terraform-managed GCP/Cloudflare infra.
+
+Nearest `AGENTS.md` wins. Read this file first, then the closest child file for the area you are changing.
+
+## STRUCTURE
+```text
+./
+├── .github/workflows/ # CI/CD entrypoint
+├── scripts/ # deploy and SSH tunnel helpers
+├── terraform/ # GCP + Cloudflare infra
+├── nginx/ # reverse proxy for prod/staging apps
+├── src/main/java/umc/cockple/demo/domain/ # business slices
+├── src/main/java/umc/cockple/demo/global/ # cross-cutting infra
+├── src/main/resources/ # Spring profiles + Flyway
+├── src/main/generated/ # generated QueryDSL Q-types
+└── src/test/java/umc/cockple/demo/ # integration/service tests + fixtures
+```
+
+## WHERE TO LOOK
+| Task | Location | Notes |
+|------|----------|-------|
+| 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 |
+| 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 |
+| Shared infra | `src/main/java/umc/cockple/demo/global/` | response, exception, jwt, oauth2, config |
+| Test conventions | `src/test/java/umc/cockple/demo/` | shared fixtures + integration base live here |
+| Local stack | `docker-compose.yml`, `nginx/`, `scripts/` | prod/staging services share one compose file |
+| CI/CD | `.github/workflows/` | PR builds on `develop`/`main`; push deploys by branch |
+| Infra changes | `terraform/` | GCP compute/network/storage + Cloudflare |
+
+## CONVENTIONS
+- Domain code is organized as feature slices, not horizontal layers across the repo.
+- Controllers usually return `BaseResponse` or `ResponseEntity>`.
+- Error codes are per-domain enums and use a documented `1xx/2xx/3xx/4xx` meaning split.
+- `application.yml` enforces `ddl-auto: validate`; schema changes belong in Flyway under `src/main/resources/db/migration/`.
+- QueryDSL generated sources are wired through Gradle; handwritten code lives under `src/main/java`, generated code under `src/main/generated` or `build/generated/...`.
+- Tests split into integration (`MockMvc` + Testcontainers profile) and service/unit (`MockitoExtension`) styles.
+
+## 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 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/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`.
+
+## COMMANDS
+```bash
+./gradlew.bat build
+./gradlew.bat test
+./gradlew.bat bootRun
+docker compose config --services
+bash scripts/tunnel.sh
+```
+
+## CHILD GUIDES
+- `.github/workflows/AGENTS.md`
+- `scripts/AGENTS.md`
+- `terraform/AGENTS.md`
+- `src/main/java/umc/cockple/demo/domain/AGENTS.md`
+- `src/main/java/umc/cockple/demo/domain/chat/AGENTS.md`
+- `src/main/java/umc/cockple/demo/domain/exercise/AGENTS.md`
+- `src/main/java/umc/cockple/demo/global/AGENTS.md`
+- `src/main/java/umc/cockple/demo/global/config/AGENTS.md`
+- `src/test/java/umc/cockple/demo/AGENTS.md`
+
+## NOTES
+- CI writes `src/main/resources/application.yml` from GitHub secrets before building.
+- CD copies only `docker-compose.yml`, `init-db.sql`, `nginx/`, and `scripts/` to the server; deploy behavior lives in both workflow and shell script.
+- Local profile expects MySQL on `3307` and Redis on `6380`; `scripts/tunnel.sh` exposes those ports through SSH forwarding.
diff --git a/build.gradle b/build.gradle
index 57828e982..97dfd314c 100644
--- a/build.gradle
+++ b/build.gradle
@@ -95,6 +95,13 @@ dependencies {
// redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'io.lettuce:lettuce-core'
+
+ // firebase
+ implementation 'com.google.firebase:firebase-admin:9.7.1'
+
+ // flyway
+ implementation 'org.flywaydb:flyway-core'
+ implementation 'org.flywaydb:flyway-mysql'
}
tasks.named('test') {
diff --git a/docker-compose.yml b/docker-compose.yml
index 605c9893b..2a883bb6a 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -13,7 +13,7 @@ services:
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
- - --innodb-buffer-pool-size=256M
+ - --innodb-buffer-pool-size=512M
volumes:
- mysql-data:/var/lib/mysql
- ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql:ro
@@ -22,8 +22,8 @@ services:
interval: 10s
timeout: 5s
retries: 5
- mem_limit: 512m
- memswap_limit: 768m
+ mem_limit: 1g
+ memswap_limit: 1536m
redis:
image: redis:7-alpine
@@ -63,6 +63,7 @@ services:
KAKAO_REDIRECT_URI: ${KAKAO_REDIRECT_URI_PROD}
KAKAO_ADMIN_KEY: ${KAKAO_ADMIN_KEY}
JWT_SECRET_KEY: ${JWT_SECRET_KEY}
+ FIREBASE_SERVICE_ACCOUNT_KEY: ${FIREBASE_SERVICE_ACCOUNT_KEY}
depends_on:
mysql:
condition: service_healthy
@@ -85,6 +86,7 @@ services:
KAKAO_REDIRECT_URI: ${KAKAO_REDIRECT_URI_STAGING}
KAKAO_ADMIN_KEY: ${KAKAO_ADMIN_KEY}
JWT_SECRET_KEY: ${JWT_SECRET_KEY}
+ FIREBASE_SERVICE_ACCOUNT_KEY: ${FIREBASE_SERVICE_ACCOUNT_KEY}
depends_on:
mysql:
condition: service_healthy
diff --git a/nginx/conf.d/prod.conf b/nginx/conf.d/prod.conf
index 6630299d4..635b2a3cf 100644
--- a/nginx/conf.d/prod.conf
+++ b/nginx/conf.d/prod.conf
@@ -10,7 +10,10 @@ server {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- proxy_set_header X-Forwarded-Proto https;
+ proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
+
+ proxy_read_timeout 3600s;
+ proxy_send_timeout 3600s;
}
location / {
@@ -18,6 +21,6 @@ server {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- proxy_set_header X-Forwarded-Proto https;
+ proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
}
}
diff --git a/nginx/conf.d/staging.conf b/nginx/conf.d/staging.conf
index e050fc273..dc016b844 100644
--- a/nginx/conf.d/staging.conf
+++ b/nginx/conf.d/staging.conf
@@ -11,6 +11,9 @@ server {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
+
+ proxy_read_timeout 3600s;
+ proxy_send_timeout 3600s;
}
location / {
diff --git a/nginx/nginx.conf b/nginx/nginx.conf
index 2ce913f67..8f66e30c5 100644
--- a/nginx/nginx.conf
+++ b/nginx/nginx.conf
@@ -21,5 +21,9 @@ http {
keepalive_timeout 65;
client_max_body_size 30M;
+ gzip on;
+ gzip_types application/json application/javascript text/plain text/css;
+ gzip_min_length 256;
+
include /etc/nginx/conf.d/*.conf;
}
diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md
new file mode 100644
index 000000000..edc6ceeed
--- /dev/null
+++ b/scripts/AGENTS.md
@@ -0,0 +1,28 @@
+# SCRIPTS GUIDE
+
+Apply root `AGENTS.md` first. This file only applies to `scripts/*`.
+
+## OVERVIEW
+Shell scripts handle server deployment and local SSH tunneling into the remote stack.
+
+## WHERE TO LOOK
+| Task | Location | Notes |
+|------|----------|-------|
+| Branch-aware deploy | `deploy.sh` | writes `.env`, pulls image tag, restarts one app service |
+| Remote DB/Redis access | `tunnel.sh` | forwards MySQL to `3307`, Redis to `6380` |
+
+## CONVENTIONS
+- `deploy.sh` treats `main` as `cockple-app:latest`; any other branch path becomes `cockple-app-staging:staging`.
+- Deploy always starts `mysql`, `redis`, and `nginx` before replacing the app container.
+- Health checks are part of the script, not just Docker Compose.
+
+## ANTI-PATTERNS
+- Do not change forwarded local ports without matching `application-local.yml`.
+- Do not add new required env vars in scripts without updating workflow `envs` and compose.
+- Do not bypass the health-check loop when changing deploy behavior.
+
+## COMMANDS
+```bash
+bash scripts/deploy.sh
+bash scripts/tunnel.sh
+```
diff --git a/scripts/deploy.sh b/scripts/deploy.sh
index 32fa00406..7a96ce079 100644
--- a/scripts/deploy.sh
+++ b/scripts/deploy.sh
@@ -22,7 +22,9 @@ KAKAO_REDIRECT_URI_PROD=${KAKAO_REDIRECT_URI_PROD}
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}
EOF
+echo "${FIREBASE_SERVICE_ACCOUNT_KEY}" > /home/ubuntu/cockple/firebase-service-account.json
echo "=== 배포 전 상태 ==="
sudo docker ps
diff --git a/src/main/java/umc/cockple/demo/domain/AGENTS.md b/src/main/java/umc/cockple/demo/domain/AGENTS.md
new file mode 100644
index 000000000..50d2ca9b7
--- /dev/null
+++ b/src/main/java/umc/cockple/demo/domain/AGENTS.md
@@ -0,0 +1,32 @@
+# DOMAIN GUIDE
+
+Apply root `AGENTS.md` first. This file covers feature slices under `domain/`.
+
+## OVERVIEW
+Business logic is organized by feature slice, usually with `controller`, `converter`, `domain`, `dto`, `enums`, `exception`, `repository`, and `service` subpackages.
+
+## WHERE TO LOOK
+| Task | Location | Notes |
+|------|----------|-------|
+| Member/account flows | `member/` | auth-adjacent domain code |
+| Party lifecycle | `party/` | joins, invitations, roles, events |
+| Exercise lifecycle | `exercise/` | largest slice; read child guide |
+| Chat realtime flows | `chat/` | read child guide |
+| Notifications | `notification/` | FCM-backed notifications |
+| Simpler slices | `bookmark/`, `contest/`, `file/`, `terms/` | mostly standard pattern |
+
+## CONVENTIONS
+- Domain-specific errors live in each slice’s `exception/*ErrorCode.java`.
+- Controllers expose `/api/...` endpoints and use global response wrappers.
+- Shared enums/base entities come from `global/`; slice-specific rules stay here.
+- `party/` and `chat/` add `events/`; `party/` also has `utils/`.
+- QueryDSL generated code mirrors entity packages elsewhere; do not mix handwritten logic into generated folders.
+
+## ANTI-PATTERNS
+- Do not move business rules into `global/` just because multiple slices depend on them.
+- Do not bypass slice error codes with ad hoc strings or generic exceptions.
+- Do not edit generated Q-types to “fix” repository behavior.
+
+## CHILD GUIDES
+- `chat/AGENTS.md`
+- `exercise/AGENTS.md`
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 59bb3c9ed..0d22c4bd5 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
@@ -7,7 +7,6 @@
import umc.cockple.demo.domain.bookmark.dto.GetAllExerciseBookmarksResponseDTO;
import umc.cockple.demo.domain.bookmark.dto.GetAllPartyBookmarkResponseDTO;
import umc.cockple.demo.domain.exercise.domain.Exercise;
-import umc.cockple.demo.domain.image.service.ImageService;
import umc.cockple.demo.domain.party.domain.Party;
import umc.cockple.demo.domain.party.domain.PartyLevel;
import umc.cockple.demo.domain.party.enums.ActivityTime;
diff --git a/src/main/java/umc/cockple/demo/domain/bookmark/service/BookmarkQueryService.java b/src/main/java/umc/cockple/demo/domain/bookmark/service/BookmarkQueryService.java
index 25f3b95eb..709ef03f2 100644
--- a/src/main/java/umc/cockple/demo/domain/bookmark/service/BookmarkQueryService.java
+++ b/src/main/java/umc/cockple/demo/domain/bookmark/service/BookmarkQueryService.java
@@ -12,7 +12,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.image.service.ImageService;
+import umc.cockple.demo.domain.file.service.FileService;
import umc.cockple.demo.domain.member.domain.Member;
import umc.cockple.demo.domain.member.exception.MemberErrorCode;
import umc.cockple.demo.domain.member.exception.MemberException;
@@ -43,7 +43,7 @@ public class BookmarkQueryService {
private final MemberExerciseRepository memberExerciseRepository;
private final MemberRepository memberRepository;
private final BookmarkConverter bookmarkConverter;
- private final ImageService imageService;
+ private final FileService fileService;
public List getAllExerciseBookmarks(Long memberId, BookmarkedExerciseOrderType orderType) {
// 회원 조회하기
@@ -133,7 +133,7 @@ private ActivityTime makeActiveTime(Exercise exercise) {
private String getImageUrl(PartyImg partyImg) {
if (partyImg != null && partyImg.getImgKey() != null && !partyImg.getImgKey().isBlank()) {
- return imageService.getUrlFromKey(partyImg.getImgKey());
+ return fileService.getUrlFromKey(partyImg.getImgKey());
}
return null;
}
diff --git a/src/main/java/umc/cockple/demo/domain/chat/AGENTS.md b/src/main/java/umc/cockple/demo/domain/chat/AGENTS.md
new file mode 100644
index 000000000..cf075e565
--- /dev/null
+++ b/src/main/java/umc/cockple/demo/domain/chat/AGENTS.md
@@ -0,0 +1,27 @@
+# CHAT GUIDE
+
+Apply parent guides first. This file only covers `domain/chat/`.
+
+## OVERVIEW
+Chat mixes REST queries with WebSocket transport, Redis-backed subscription/cache behavior, and domain events.
+
+## 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 |
+| 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 |
+
+## CONVENTIONS
+- `WebSocketConfig` 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`.
+
+## ANTI-PATTERNS
+- Do not treat chat as HTTP-only; transport, cache, and event flow are part of the slice.
+- Do not bypass membership/access validation before room or message operations.
+- Do not mix socket session bookkeeping into controllers or converters.
diff --git a/src/main/java/umc/cockple/demo/domain/chat/controller/ChatController.java b/src/main/java/umc/cockple/demo/domain/chat/controller/ChatController.java
index 9c087c1c7..7ce4fd011 100644
--- a/src/main/java/umc/cockple/demo/domain/chat/controller/ChatController.java
+++ b/src/main/java/umc/cockple/demo/domain/chat/controller/ChatController.java
@@ -11,7 +11,6 @@
import umc.cockple.demo.domain.chat.dto.*;
import umc.cockple.demo.domain.chat.service.ChatCommandService;
import umc.cockple.demo.domain.chat.service.ChatFileService;
-import umc.cockple.demo.domain.chat.service.ChatImageService;
import umc.cockple.demo.domain.chat.service.ChatQueryService;
import umc.cockple.demo.global.response.BaseResponse;
import umc.cockple.demo.global.response.code.status.CommonSuccessCode;
@@ -27,7 +26,6 @@ public class ChatController {
private final ChatQueryService chatQueryService;
private final ChatCommandService chatCommandService;
private final ChatFileService chatFileService;
- private final ChatImageService chatImageService;
@GetMapping(value = "/parties")
@Operation(summary = "모임 채팅방 목록 조회", description = "회원이 자신의 모임 채팅방 목록을 조회합니다.")
@@ -139,33 +137,6 @@ public ResponseEntity downloadFile(
return chatFileService.downloadFile(fileId, token);
}
- //TODO: 파일 다운로드 토큰 발급 API와 통합
- @PostMapping("/images/{imageId}/download-token")
- @Operation(summary = "채팅 이미지 다운로드 토큰 발급", description = "채팅방에 업로드된 특정 이미지를 다운로드할 수 있는 일회용 토큰을 발급합니다.")
- @ApiResponse(responseCode = "200", description = "토큰 발급 성공")
- @ApiResponse(responseCode = "403", description = "이미지 접근 권한 없음")
- @ApiResponse(responseCode = "404", description = "존재하지 않는 이미지")
- public BaseResponse issueImageDownloadToken(
- @PathVariable Long imageId
- ) {
- Long memberId = SecurityUtil.getCurrentMemberId();
- ChatDownloadTokenDTO.Response response = chatImageService.issueDownloadToken(imageId, memberId);
- return BaseResponse.success(CommonSuccessCode.OK, response);
- }
-
- //TODO: 파일 다운로드 API와 통합
- @GetMapping("/images/{imageId}/download")
- @Operation(summary = "채팅 이미지 다운로드", description = "발급받은 다운로드 토큰을 검증하고, 유효할 경우 실제 이미지 데이터를 반환합니다.")
- @ApiResponse(responseCode = "200", description = "이미지 다운로드 성공")
- @ApiResponse(responseCode = "403", description = "유효하지 않거나 만료된 토큰")
- @ApiResponse(responseCode = "404", description = "존재하지 않는 이미지")
- public ResponseEntity downloadImage(
- @PathVariable Long imageId,
- @RequestParam String token
- ) {
- return chatImageService.downloadImage(imageId, token);
- }
-
@GetMapping("/parties/{partyId}")
@Operation(summary = "모임 채팅방 ID 조회")
@ApiResponse(responseCode = "200", description = "채팅방 ID 조회 성공")
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 cfd6ab05d..c1b93d071 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
@@ -118,16 +118,14 @@ public List toMemberInfo(List images,
- List files,
+ List files,
ChatMessage savedMessage, Member sender, String senderProfileImageUrl, int unreadCount) {
return WebSocketMessageDTO.MessageResponse.builder()
.type(WebSocketMessageType.SEND)
.chatRoomId(chatRoomId)
.messageId(savedMessage.getId())
.content(content)
- .images(images)
- .files(files)
+ .images(files)
.senderId(sender.getId())
.senderName(sender.getMemberName())
.senderProfileImageUrl(senderProfileImageUrl)
@@ -171,7 +169,7 @@ public ChatRoomDetailDTO.ChatRoomInfo toChatRoomDetailChatRoomInfo(
public ChatCommonDTO.MessageInfo toCommonMessageInfo(
ChatMessage message,
String senderProfileImageUrl,
- List processedImages,
+ List processedFiles,
boolean isMyMessage,
boolean isSenderWithdrawn) {
@@ -183,21 +181,21 @@ public ChatCommonDTO.MessageInfo toCommonMessageInfo(
.isSenderWithdrawn(isSenderWithdrawn)
.content(message.getContent())
.messageType(message.getType())
- .images(processedImages)
+ .images(processedFiles)
.timestamp(message.getCreatedAt())
.isMyMessage(isMyMessage)
.build();
}
- public ChatCommonDTO.ImageInfo toImageInfo(ChatMessageImg img, String imageUrl) {
- return ChatCommonDTO.ImageInfo.builder()
- .imageId(img.getId())
- .imageUrl(imageUrl)
- .imgOrder(img.getImgOrder())
- .isEmoji(img.getIsEmoji())
- .originalFileName(img.getOriginalFileName())
- .fileSize(img.getFileSize())
- .fileType(img.getFileType())
+ public ChatCommonDTO.FileInfo toFileInfo(ChatMessageFile file, String fileUrl) {
+ return ChatCommonDTO.FileInfo.builder()
+ .imageId(file.getId())
+ .imageUrl(fileUrl)
+ .imgOrder(file.getFileOrder())
+ .isEmoji(file.getIsEmoji())
+ .originalFileName(file.getOriginalFileName())
+ .fileSize(file.getFileSize())
+ .fileType(file.getFileType())
.build();
}
diff --git a/src/main/java/umc/cockple/demo/domain/chat/domain/ChatMessage.java b/src/main/java/umc/cockple/demo/domain/chat/domain/ChatMessage.java
index 20b9c7999..d74602837 100644
--- a/src/main/java/umc/cockple/demo/domain/chat/domain/ChatMessage.java
+++ b/src/main/java/umc/cockple/demo/domain/chat/domain/ChatMessage.java
@@ -38,10 +38,6 @@ public class ChatMessage extends BaseEntity {
@Column(nullable = false)
private Boolean isDeleted;
- @OneToMany(mappedBy = "chatMessage", cascade = CascadeType.ALL, orphanRemoval = true)
- @Builder.Default
- private List chatMessageImgs = new ArrayList<>();
-
@OneToMany(mappedBy = "chatMessage", cascade = CascadeType.ALL, orphanRemoval = true)
@Builder.Default
private List chatMessageFiles = new ArrayList<>();
@@ -61,11 +57,11 @@ public String getDisplayContent() {
return this.content;
}
- if (this.chatMessageImgs != null && !this.chatMessageImgs.isEmpty()) {
- ChatMessageImg firstImg = this.chatMessageImgs.get(0);
- int count = this.chatMessageImgs.size();
+ if (this.chatMessageFiles != null && !this.chatMessageFiles.isEmpty()) {
+ ChatMessageFile firstFile = this.chatMessageFiles.get(0);
+ int count = this.chatMessageFiles.size();
- if (firstImg.getIsEmoji()) {
+ if (firstFile.getIsEmoji()) {
return "이모티콘을 보냈습니다.";
} else {
return count > 1 ?
@@ -74,14 +70,6 @@ public String getDisplayContent() {
}
}
- // 파일이 있는 경우
- if (this.chatMessageFiles != null && !this.chatMessageFiles.isEmpty()) {
- int fileCount = this.chatMessageFiles.size();
- return fileCount > 1 ?
- String.format("파일 %d개를 보냈습니다.", fileCount) :
- "파일을 보냈습니다.";
- }
-
return "메시지";
}
}
diff --git a/src/main/java/umc/cockple/demo/domain/chat/domain/ChatMessageFile.java b/src/main/java/umc/cockple/demo/domain/chat/domain/ChatMessageFile.java
index 53f27751c..d429ea9c6 100644
--- a/src/main/java/umc/cockple/demo/domain/chat/domain/ChatMessageFile.java
+++ b/src/main/java/umc/cockple/demo/domain/chat/domain/ChatMessageFile.java
@@ -6,9 +6,9 @@
@Entity
@Getter
-@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
+@Builder
public class ChatMessageFile extends BaseEntity {
@Id
@@ -22,6 +22,9 @@ public class ChatMessageFile extends BaseEntity {
@Column(nullable = false)
private String fileKey;
+ @Column(nullable = false)
+ private Integer fileOrder;
+
@Column(nullable = false)
private String originalFileName;
@@ -29,13 +32,31 @@ public class ChatMessageFile extends BaseEntity {
private String fileType;
- public static ChatMessageFile create(ChatMessage message, String originalFileName, String fileKey, Long fileSize, String fileType) {
+ @Column(nullable = false)
+ @Builder.Default
+ private Boolean isEmoji = false;
+
+ public static ChatMessageFile create(ChatMessage message, String fileKey, Integer fileOrder, String originalFileName, Long fileSize, String fileType) {
+ boolean isEmoji = isEmojiFileName(originalFileName);
+
return ChatMessageFile.builder()
.chatMessage(message)
.fileKey(fileKey)
+ .fileOrder(fileOrder)
.originalFileName(originalFileName)
.fileSize(fileSize)
.fileType(fileType)
+ .isEmoji(isEmoji)
.build();
}
+
+ private static boolean isEmojiFileName(String originalFileName) {
+ if (originalFileName == null) {
+ return false;
+ }
+ return "emoji.png".equalsIgnoreCase(originalFileName.trim());
+ }
}
+
+
+
diff --git a/src/main/java/umc/cockple/demo/domain/chat/domain/ChatMessageImg.java b/src/main/java/umc/cockple/demo/domain/chat/domain/ChatMessageImg.java
deleted file mode 100644
index bf2e28299..000000000
--- a/src/main/java/umc/cockple/demo/domain/chat/domain/ChatMessageImg.java
+++ /dev/null
@@ -1,62 +0,0 @@
-package umc.cockple.demo.domain.chat.domain;
-
-import jakarta.persistence.*;
-import lombok.*;
-import umc.cockple.demo.global.common.BaseEntity;
-
-@Entity
-@Getter
-@NoArgsConstructor(access = AccessLevel.PROTECTED)
-@AllArgsConstructor
-@Builder
-public class ChatMessageImg extends BaseEntity {
-
- @Id
- @GeneratedValue(strategy = GenerationType.IDENTITY)
- private Long id;
-
- @ManyToOne(fetch = FetchType.LAZY)
- @JoinColumn(name = "chat_message_id")
- private ChatMessage chatMessage;
-
- @Column(nullable = false)
- private String imgKey;
-
- @Column(nullable = false)
- private Integer imgOrder;
-
- @Column(nullable = false)
- private String originalFileName;
-
- private Long fileSize;
-
- private String fileType;
-
- @Column(nullable = false)
- @Builder.Default
- private Boolean isEmoji = false;
-
- public static ChatMessageImg create(ChatMessage message, String imgKey, Integer imgOrder, String originalFileName, Long fileSize, String fileType) {
- boolean isEmoji = isEmojiFileName(originalFileName);
-
- return ChatMessageImg.builder()
- .chatMessage(message)
- .imgKey(imgKey)
- .imgOrder(imgOrder)
- .originalFileName(originalFileName)
- .fileSize(fileSize)
- .fileType(fileType)
- .isEmoji(isEmoji)
- .build();
- }
-
- private static boolean isEmojiFileName(String originalFileName) {
- if (originalFileName == null) {
- return false;
- }
- return "emoji.png".equalsIgnoreCase(originalFileName.trim());
- }
-}
-
-
-
diff --git a/src/main/java/umc/cockple/demo/domain/chat/dto/ChatCommonDTO.java b/src/main/java/umc/cockple/demo/domain/chat/dto/ChatCommonDTO.java
index 75e0851c5..e58dc5bde 100644
--- a/src/main/java/umc/cockple/demo/domain/chat/dto/ChatCommonDTO.java
+++ b/src/main/java/umc/cockple/demo/domain/chat/dto/ChatCommonDTO.java
@@ -17,14 +17,14 @@ public record MessageInfo(
boolean isSenderWithdrawn,
String content,
MessageType messageType,
- List images,
+ List images,
LocalDateTime timestamp,
boolean isMyMessage
) {
}
@Builder
- public record ImageInfo(
+ public record FileInfo(
Long imageId,
String imageUrl,
Integer imgOrder,
diff --git a/src/main/java/umc/cockple/demo/domain/chat/dto/ChatMessageDTO.java b/src/main/java/umc/cockple/demo/domain/chat/dto/ChatMessageDTO.java
index cd00d5d4f..0852c3d35 100644
--- a/src/main/java/umc/cockple/demo/domain/chat/dto/ChatMessageDTO.java
+++ b/src/main/java/umc/cockple/demo/domain/chat/dto/ChatMessageDTO.java
@@ -26,7 +26,7 @@ public record MessageInfo(
boolean isSenderWithdrawn,
String content,
MessageType messageType,
- List images,
+ List images,
LocalDateTime timestamp,
boolean isMyMessage
) {
diff --git a/src/main/java/umc/cockple/demo/domain/chat/dto/ChatRoomDetailDTO.java b/src/main/java/umc/cockple/demo/domain/chat/dto/ChatRoomDetailDTO.java
index 34176c8fe..60ebeab33 100644
--- a/src/main/java/umc/cockple/demo/domain/chat/dto/ChatRoomDetailDTO.java
+++ b/src/main/java/umc/cockple/demo/domain/chat/dto/ChatRoomDetailDTO.java
@@ -38,7 +38,7 @@ public record MessageInfo(
boolean isSenderWithdrawn,
String content,
MessageType messageType,
- List images,
+ List images,
LocalDateTime timestamp,
boolean isMyMessage
) {
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 138002627..1787a00c7 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
@@ -13,21 +13,11 @@ public record Request(
Long chatRoomId,
List memberRooms,
String content,
- List files,
- List images,
+ List images,
Long lastReadMessageId
) {
@Builder
public record FileInfo(
- String fileKey,
- String originalFileName,
- Long fileSize,
- String fileType
- ) {
- }
-
- @Builder
- public record ImageInfo(
String imgKey,
Integer imgOrder,
String originalFileName,
@@ -53,22 +43,13 @@ public record MessageResponse(
Long chatRoomId,
Long messageId,
String content,
- List files,
- List images,
+ List images,
Long senderId,
String senderName,
String senderProfileImageUrl,
LocalDateTime timestamp,
Integer unreadCount
) {
- @Builder
- public record FileInfo(
- Long fileId,
- String originalFileName,
- Long fileSize,
- String fileType
- ) {
- }
}
@Builder
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
index 143c81e06..983db218d 100644
--- a/src/main/java/umc/cockple/demo/domain/chat/events/ChatEventListener.java
+++ b/src/main/java/umc/cockple/demo/domain/chat/events/ChatEventListener.java
@@ -36,7 +36,7 @@ public void handleChatMessageSend(ChatMessageSendEvent event) {
event.chatRoomId(), event.senderId());
try {
chatSendService
- .sendMessage(event.chatRoomId(), event.content(), event.files(), event.images(), event.senderId());
+ .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/events/ChatMessageSendEvent.java b/src/main/java/umc/cockple/demo/domain/chat/events/ChatMessageSendEvent.java
index 84192b2fd..e90e3f2d8 100644
--- a/src/main/java/umc/cockple/demo/domain/chat/events/ChatMessageSendEvent.java
+++ b/src/main/java/umc/cockple/demo/domain/chat/events/ChatMessageSendEvent.java
@@ -2,7 +2,6 @@
import lombok.Builder;
import umc.cockple.demo.domain.chat.dto.WebSocketMessageDTO.Request.FileInfo;
-import umc.cockple.demo.domain.chat.dto.WebSocketMessageDTO.Request.ImageInfo;
import umc.cockple.demo.domain.chat.enums.MessageType;
import java.util.List;
@@ -12,17 +11,15 @@ public record ChatMessageSendEvent(
Long chatRoomId,
String content,
List files,
- List images,
Long senderId,
MessageType messageType
) {
public static ChatMessageSendEvent create(
- Long chatRoomId, String content, List files, List images, Long senderId) {
+ Long chatRoomId, String content, List files, Long senderId) {
return ChatMessageSendEvent.builder()
.chatRoomId(chatRoomId)
.content(content)
.files(files)
- .images(images)
.senderId(senderId)
.messageType(MessageType.TEXT)
.build();
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
index 21f4fb80e..759614709 100644
--- a/src/main/java/umc/cockple/demo/domain/chat/handler/ChatWebSocketHandler.java
+++ b/src/main/java/umc/cockple/demo/domain/chat/handler/ChatWebSocketHandler.java
@@ -130,11 +130,11 @@ public void handleTransportError(WebSocketSession session, Throwable exception)
private void handleSendMessage(WebSocketSession session, WebSocketMessageDTO.Request request, Long memberId) {
try {
chatValidator.validateSendRequest(
- request.chatRoomId(), request.content(), request.files(), request.images(), memberId);
+ request.chatRoomId(), request.content(), request.images(), memberId);
ChatMessageSendEvent sendEvent =
ChatMessageSendEvent.create(
- request.chatRoomId(), request.content(), request.files(), request.images(), memberId);
+ request.chatRoomId(), request.content(), request.images(), memberId);
eventPublisher.publishEvent(sendEvent);
} catch (ChatException e) {
diff --git a/src/main/java/umc/cockple/demo/domain/chat/repository/ChatImageRepository.java b/src/main/java/umc/cockple/demo/domain/chat/repository/ChatImageRepository.java
deleted file mode 100644
index c26bb2572..000000000
--- a/src/main/java/umc/cockple/demo/domain/chat/repository/ChatImageRepository.java
+++ /dev/null
@@ -1,7 +0,0 @@
-package umc.cockple.demo.domain.chat.repository;
-
-import org.springframework.data.jpa.repository.JpaRepository;
-import umc.cockple.demo.domain.chat.domain.ChatMessageImg;
-
-public interface ChatImageRepository extends JpaRepository {
-}
diff --git a/src/main/java/umc/cockple/demo/domain/chat/repository/ChatMessageRepository.java b/src/main/java/umc/cockple/demo/domain/chat/repository/ChatMessageRepository.java
index dd02f9407..6e511a3be 100644
--- a/src/main/java/umc/cockple/demo/domain/chat/repository/ChatMessageRepository.java
+++ b/src/main/java/umc/cockple/demo/domain/chat/repository/ChatMessageRepository.java
@@ -26,19 +26,19 @@ int countUnreadMessages(
@Query("""
SELECT m FROM ChatMessage m
JOIN FETCH m.sender
- LEFT JOIN FETCH m.chatMessageImgs
+ LEFT JOIN FETCH m.chatMessageFiles
WHERE m.chatRoom.id = :chatRoomId
AND m.isDeleted = false
ORDER BY m.createdAt DESC
""")
- List findRecentMessagesWithImages(
+ List findRecentMessagesWithFiles(
@Param("chatRoomId") Long chatRoomId,
Pageable pageable);
@Query("""
SELECT m FROM ChatMessage m
JOIN FETCH m.sender
- LEFT JOIN FETCH m.chatMessageImgs
+ LEFT JOIN FETCH m.chatMessageFiles
WHERE m.chatRoom.id = :chatRoomId
AND m.id < :cursor
ORDER BY m.createdAt DESC
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 42b343533..17e8c0f1f 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
@@ -1,6 +1,7 @@
package umc.cockple.demo.domain.chat.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.chat.domain.ChatRoomMember;
@@ -26,8 +27,6 @@ Optional findByChatRoomIdAndMemberId(
List findByChatRoomId(Long id);
- List findAllByMemberId(Long id);
-
@Query("""
SELECT crm FROM ChatRoomMember crm
JOIN FETCH crm.member m
@@ -62,5 +61,15 @@ Optional findCounterPartWithMember(
@Query("SELECT crm.member.id FROM ChatRoomMember crm WHERE crm.chatRoom.id = :chatRoomId")
List findMemberIdsByChatRoomId(Long chatRoomId);
+
+ @Query("""
+ SELECT counterPart FROM ChatRoomMember counterPart
+ WHERE counterPart.chatRoom.type = 'DIRECT'
+ AND counterPart.member.id != :memberId
+ AND counterPart.chatRoom.id IN (
+ SELECT mine.chatRoom.id FROM ChatRoomMember mine WHERE mine.member.id = :memberId
+ )
+ """)
+ List findDirectChatCounterParts(@Param("memberId") Long memberId);
}
diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/ChatFileServiceImpl.java b/src/main/java/umc/cockple/demo/domain/chat/service/ChatFileServiceImpl.java
index 0c0130099..6ba2ba251 100644
--- a/src/main/java/umc/cockple/demo/domain/chat/service/ChatFileServiceImpl.java
+++ b/src/main/java/umc/cockple/demo/domain/chat/service/ChatFileServiceImpl.java
@@ -20,7 +20,7 @@
import umc.cockple.demo.domain.chat.repository.ChatFileRepository;
import umc.cockple.demo.domain.chat.repository.ChatRoomMemberRepository;
import umc.cockple.demo.domain.chat.repository.DownloadTokenRepository;
-import umc.cockple.demo.domain.image.service.ImageService;
+import umc.cockple.demo.domain.file.service.FileService;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
@@ -35,7 +35,7 @@ public class ChatFileServiceImpl implements ChatFileService{
private final DownloadTokenRepository downloadTokenRepository;
private final ChatConverter chatConverter;
private final ChatRoomMemberRepository chatRoomMemberRepository;
- private final ImageService imageService;
+ private final FileService fileService;
private static final int TOKEN_VALIDITY_SECONDS = 180;
@Override
@@ -66,7 +66,7 @@ public ResponseEntity downloadFile(Long fileId, String token) {
ChatMessageFile chatFile = findChatFileOrThrow(fileId);
//GCS에서 파일 객체 직접 가져오기
- Blob blob = imageService.downloadFile(chatFile.getFileKey());
+ Blob blob = fileService.downloadFile(chatFile.getFileKey());
ResponseEntity responseEntity = createDownloadResponseEntity(chatFile, blob);
log.info("파일 다운로드 완료 - fileName: {}", chatFile.getOriginalFileName());
diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/ChatImageService.java b/src/main/java/umc/cockple/demo/domain/chat/service/ChatImageService.java
deleted file mode 100644
index 012113853..000000000
--- a/src/main/java/umc/cockple/demo/domain/chat/service/ChatImageService.java
+++ /dev/null
@@ -1,10 +0,0 @@
-package umc.cockple.demo.domain.chat.service;
-
-import org.springframework.core.io.Resource;
-import org.springframework.http.ResponseEntity;
-import umc.cockple.demo.domain.chat.dto.ChatDownloadTokenDTO;
-
-public interface ChatImageService {
- ChatDownloadTokenDTO.Response issueDownloadToken(Long imageId, Long memberId);
- ResponseEntity downloadImage(Long imageId, String token);
-}
\ No newline at end of file
diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/ChatImageServiceImpl.java b/src/main/java/umc/cockple/demo/domain/chat/service/ChatImageServiceImpl.java
deleted file mode 100644
index e7d778d0b..000000000
--- a/src/main/java/umc/cockple/demo/domain/chat/service/ChatImageServiceImpl.java
+++ /dev/null
@@ -1,116 +0,0 @@
-package umc.cockple.demo.domain.chat.service;
-
-import com.google.cloud.storage.Blob;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.core.io.InputStreamResource;
-import org.springframework.core.io.Resource;
-import org.springframework.http.ContentDisposition;
-import org.springframework.http.HttpHeaders;
-import org.springframework.http.MediaType;
-import org.springframework.http.ResponseEntity;
-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.ChatMessageImg;
-import umc.cockple.demo.domain.chat.domain.DownloadToken;
-import umc.cockple.demo.domain.chat.dto.ChatDownloadTokenDTO;
-import umc.cockple.demo.domain.chat.exception.ChatErrorCode;
-import umc.cockple.demo.domain.chat.exception.ChatException;
-import umc.cockple.demo.domain.chat.repository.ChatImageRepository;
-import umc.cockple.demo.domain.chat.repository.ChatRoomMemberRepository;
-import umc.cockple.demo.domain.chat.repository.DownloadTokenRepository;
-import umc.cockple.demo.domain.image.service.ImageService;
-
-import java.nio.charset.StandardCharsets;
-import java.time.LocalDateTime;
-
-@Service
-@Transactional
-@RequiredArgsConstructor
-@Slf4j
-public class ChatImageServiceImpl implements ChatImageService{
-
- private final ChatImageRepository chatImageRepository;
- private final DownloadTokenRepository downloadTokenRepository;
- private final ChatConverter chatConverter;
- private final ChatRoomMemberRepository chatRoomMemberRepository;
- private final ImageService imageService;
- private static final int TOKEN_VALIDITY_SECONDS = 180;
-
- @Override
- public ChatDownloadTokenDTO.Response issueDownloadToken(Long fileId, Long memberId) {
- log.info("다운로드 토큰 발급 시작 - fileId: {}, memberId: {}", fileId, memberId);
-
- //이미지 파일 조회
- ChatMessageImg chatImage = findChatImageOrThrow(fileId);
-
- //사용자 검증
- validateMemberPermission(chatImage, memberId);
-
- //다운로드 토큰 생성 및 저장
- DownloadToken downloadToken = DownloadToken.create(fileId, memberId, TOKEN_VALIDITY_SECONDS);
- downloadTokenRepository.save(downloadToken);
-
- log.info("다운로드 토큰 발급 완료 - fileId: {}", fileId);
- return chatConverter.toDownloadTokenResponse(downloadToken, TOKEN_VALIDITY_SECONDS);
- }
-
- @Override
- public ResponseEntity downloadImage(Long imageId, String token) {
- log.info("이미지 다운로드 시작 - imageId: {}", imageId);
-
- //토큰 검증
- validateToken(imageId, token);
- //채팅 파일 조회
- ChatMessageImg chatImage = findChatImageOrThrow(imageId);
-
- //GCS에서 파일 객체 직접 가져오기
- Blob blob = imageService.downloadFile(chatImage.getImgKey());
- ResponseEntity responseEntity = createDownloadResponseEntity(chatImage, blob);
-
- log.info("이미지 다운로드 완료 - imageName: {}", chatImage.getOriginalFileName());
- return responseEntity;
- }
-
- private ChatMessageImg findChatImageOrThrow(Long imageId) {
- return chatImageRepository.findById(imageId)
- .orElseThrow(() -> new ChatException(ChatErrorCode.FILE_NOT_FOUND));
- }
-
- private void validateMemberPermission(ChatMessageImg chatImage, Long memberId) {
- Long roomId = chatImage.getChatMessage().getChatRoom().getId();
- if (!chatRoomMemberRepository.existsByChatRoomIdAndMemberId(roomId, memberId))
- throw new ChatException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED);
- }
-
- private void validateToken(Long ImageId, String tokenValue) {
- DownloadToken token = downloadTokenRepository.findByToken(tokenValue)
- .orElseThrow(() -> new ChatException(ChatErrorCode.INVALID_DOWNLOAD_TOKEN));
- //토큰 유효성 검증 (만료 시간, 이미지 ID)
- if (token.getExpiresAt().isBefore(LocalDateTime.now()) || !token.getFileId().equals(ImageId)) {
- throw new ChatException(ChatErrorCode.INVALID_DOWNLOAD_TOKEN);
- }
- //사용된 토큰 삭제
- downloadTokenRepository.delete(token);
- }
-
- private ResponseEntity createDownloadResponseEntity(ChatMessageImg chatMessageImg, Blob blob) {
- //GCS 객체에서 직접 메타데이터를 가져오기
- long contentLength = blob.getSize();
- String contentType = blob.getContentType();
- Resource resource = new InputStreamResource(new java.io.ByteArrayInputStream(blob.getContent()));
-
- //헤더 생성
- ContentDisposition contentDisposition = ContentDisposition.builder("attachment")
- .filename(chatMessageImg.getOriginalFileName(), StandardCharsets.UTF_8)
- .build();
-
- HttpHeaders headers = new HttpHeaders();
- headers.setContentDisposition(contentDisposition);
- headers.setContentType(MediaType.parseMediaType(contentType));
- headers.setContentLength(contentLength);
-
- return ResponseEntity.ok().headers(headers).body(resource);
- }
-}
\ No newline at end of file
diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/ChatProcessor.java b/src/main/java/umc/cockple/demo/domain/chat/service/ChatProcessor.java
index c9a7e846e..476fa9ae6 100644
--- a/src/main/java/umc/cockple/demo/domain/chat/service/ChatProcessor.java
+++ b/src/main/java/umc/cockple/demo/domain/chat/service/ChatProcessor.java
@@ -5,9 +5,9 @@
import org.springframework.stereotype.Component;
import umc.cockple.demo.domain.chat.converter.ChatConverter;
import umc.cockple.demo.domain.chat.domain.ChatMessage;
-import umc.cockple.demo.domain.chat.domain.ChatMessageImg;
+import umc.cockple.demo.domain.chat.domain.ChatMessageFile;
import umc.cockple.demo.domain.chat.dto.ChatCommonDTO;
-import umc.cockple.demo.domain.image.service.ImageService;
+import umc.cockple.demo.domain.file.service.FileService;
import umc.cockple.demo.domain.member.domain.Member;
import umc.cockple.demo.domain.member.domain.ProfileImg;
import umc.cockple.demo.domain.member.enums.MemberStatus;
@@ -20,7 +20,7 @@
@Slf4j
public class ChatProcessor {
- private final ImageService imageService;
+ private final FileService fileService;
private final ChatConverter chatConverter;
public List processMessages(Long memberId, List recentMessages) {
@@ -32,34 +32,34 @@ public List processMessages(Long memberId, List processedImages = processMessageImages(message);
+ List processedFiles = processMessageFiles(message);
boolean isMyMessage = isMyMessage(sender.getId(), memberId);
boolean isSenderWithdrawn = sender.getIsActive() == MemberStatus.INACTIVE;
- return chatConverter.toCommonMessageInfo(message, senderProfileImageUrl, processedImages, isMyMessage, isSenderWithdrawn);
+ return chatConverter.toCommonMessageInfo(message, senderProfileImageUrl, processedFiles, isMyMessage, isSenderWithdrawn);
}
public String generateProfileImageUrl(ProfileImg profileImg) {
if (profileImg != null && profileImg.getImgKey() != null && !profileImg.getImgKey().isBlank()) {
- return imageService.getUrlFromKey(profileImg.getImgKey());
+ return fileService.getUrlFromKey(profileImg.getImgKey());
}
return null;
}
- private List processMessageImages(ChatMessage message) {
- return message.getChatMessageImgs().stream()
- .sorted(Comparator.comparing(ChatMessageImg::getImgOrder))
- .map(this::processSingleImage)
+ private List processMessageFiles(ChatMessage message) {
+ return message.getChatMessageFiles().stream()
+ .sorted(Comparator.comparing(ChatMessageFile::getFileOrder))
+ .map(this::processSingleFile)
.toList();
}
- private ChatCommonDTO.ImageInfo processSingleImage(ChatMessageImg img) {
- String imageUrl = generateImageUrl(img);
- return chatConverter.toImageInfo(img, imageUrl);
+ private ChatCommonDTO.FileInfo processSingleFile(ChatMessageFile file) {
+ String imageUrl = generateFileUrl(file);
+ return chatConverter.toFileInfo(file, imageUrl);
}
- public String generateImageUrl(ChatMessageImg img) {
- if (img != null && img.getImgKey() != null && !img.getImgKey().isBlank()) {
- return imageService.getUrlFromKey(img.getImgKey());
+ public String generateFileUrl(ChatMessageFile file) {
+ if (file != null && file.getFileKey() != null && !file.getFileKey().isBlank()) {
+ return fileService.getUrlFromKey(file.getFileKey());
}
return null;
}
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 0b2f6b608..a87a3f528 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
@@ -20,7 +20,7 @@
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.image.service.ImageService;
+import umc.cockple.demo.domain.file.service.FileService;
import umc.cockple.demo.domain.member.domain.Member;
import umc.cockple.demo.domain.member.domain.ProfileImg;
import umc.cockple.demo.domain.member.enums.MemberStatus;
@@ -48,7 +48,7 @@ public class ChatQueryServiceImpl implements ChatQueryService {
private final MessageReadStatusRepository messageReadStatusRepository;
private final ChatConverter chatConverter;
- private final ImageService imageService;
+ private final FileService fileService;
private final ChatProcessor chatProcessor;
private final ChatRoomListCacheService chatRoomListCacheService;
@@ -305,7 +305,7 @@ private ChatRoomDetailDTO.MemberInfo buildMemberInfo(ChatRoomMember chatRoomMemb
private String getImageUrl(PartyImg partyImg) {
if (partyImg != null && partyImg.getImgKey() != null && !partyImg.getImgKey().isBlank()) {
- return imageService.getUrlFromKey(partyImg.getImgKey());
+ return fileService.getUrlFromKey(partyImg.getImgKey());
}
return null;
}
@@ -314,7 +314,7 @@ private String getImageUrl(ProfileImg profileImg) {
if (profileImg == null || profileImg.getImgKey() == null) {
return null;
}
- return imageService.getUrlFromKey(profileImg.getImgKey());
+ return fileService.getUrlFromKey(profileImg.getImgKey());
}
// ========== 조회 메서드 ==========
@@ -341,7 +341,7 @@ private List findChatRoomMembersWithMemberOrThrow(Long roomId) {
}
private List findRecentMessagesWithImages(Long roomId, Pageable pageable) {
- return chatMessageRepository.findRecentMessagesWithImages(roomId, pageable);
+ return chatMessageRepository.findRecentMessagesWithFiles(roomId, pageable);
}
private List findMessagesWithCursor(Long roomId, Long cursor, Pageable pageable) {
diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/ChatValidator.java b/src/main/java/umc/cockple/demo/domain/chat/service/ChatValidator.java
index fbe80f8ca..4a3fc6e3c 100644
--- a/src/main/java/umc/cockple/demo/domain/chat/service/ChatValidator.java
+++ b/src/main/java/umc/cockple/demo/domain/chat/service/ChatValidator.java
@@ -4,7 +4,6 @@
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import umc.cockple.demo.domain.chat.dto.WebSocketMessageDTO.Request.FileInfo;
-import umc.cockple.demo.domain.chat.dto.WebSocketMessageDTO.Request.ImageInfo;
import umc.cockple.demo.domain.chat.exception.ChatErrorCode;
import umc.cockple.demo.domain.chat.exception.ChatException;
import umc.cockple.demo.domain.chat.repository.ChatRoomMemberRepository;
@@ -20,10 +19,10 @@ public class ChatValidator {
private final ChatRoomRepository chatRoomRepository;
private final ChatRoomMemberRepository chatRoomMemberRepository;
- public void validateSendRequest(Long chatRoomId, String content, List files, List images, Long senderId) {
+ public void validateSendRequest(Long chatRoomId, String content, List files, Long senderId) {
validateChatRoom(chatRoomId);
validateChatRoomMember(chatRoomId, senderId);
- validateMessage(content, files, images);
+ validateMessage(content, files);
}
public void validateSubscriptionRequest(Long chatRoomId, Long senderId) {
@@ -63,12 +62,11 @@ private void validateChatRoomMember(Long chatRoomId, Long memberId) {
throw new ChatException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED);
}
- private void validateMessage(String content, List files, List images) {
+ private void validateMessage(String content, List files) {
boolean hasContent = content != null && !content.trim().isEmpty();
boolean hasFiles = files != null && !files.isEmpty();
- boolean hasImages = images != null && !images.isEmpty();
- if (!hasContent && !hasFiles && !hasImages) {
+ if (!hasContent && !hasFiles) {
throw new ChatException(ChatErrorCode.EMPTY_MESSAGE_NOT_ALLOWED);
}
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
index 7ad1ee46e..deaf94f69 100644
--- 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
@@ -10,7 +10,6 @@
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.dto.WebSocketMessageDTO.Request.ImageInfo;
import umc.cockple.demo.domain.chat.enums.ChatRoomType;
import umc.cockple.demo.domain.chat.enums.MessageType;
import umc.cockple.demo.domain.chat.events.ChatRoomListUpdateEvent;
@@ -49,7 +48,7 @@ public class ChatSendService {
private final ApplicationEventPublisher eventPublisher;
- public void sendMessage(Long chatRoomId, String content, List files, List images, Long senderId) {
+ public void sendMessage(Long chatRoomId, String content, List files, Long senderId) {
log.info("메시지 전송 시작 - 채팅방: {}, 발신자: {}", chatRoomId, senderId);
ChatRoom chatRoom = findChatRoom(chatRoomId);
@@ -59,7 +58,6 @@ public void sendMessage(Long chatRoomId, String content, List files, L
ChatMessage chatMessage = ChatMessage.create(chatRoom, sender, content, MessageType.TEXT);
attachFiles(chatMessage, files);
- attachImages(chatMessage, images);
ChatMessage savedMessage = chatMessageRepository.save(chatMessage);
log.info("메시지 저장 완료 - 메시지 ID: {}", savedMessage.getId());
@@ -69,14 +67,12 @@ public void sendMessage(Long chatRoomId, String content, List files, L
List activeSubscribers = subscriptionService.getActiveSubscribers(chatRoomId);
int unreadCount = chatReadService.subscribersToReadStatus(chatRoom.getId(), savedMessage.getId(), activeSubscribers, senderId);
- List responseImages =
- createResponseImageInfos(savedMessage.getChatMessageImgs());
- List responseFiles =
+ List responseFiles =
createResponseFileInfos(savedMessage.getChatMessageFiles());
log.info("메시지 브로드캐스트 시작 - 채팅방 ID: {}", chatRoomId);
WebSocketMessageDTO.MessageResponse response =
- chatConverter.toSendMessageResponse(chatRoomId, content, responseImages, responseFiles, savedMessage, sender, profileImageUrl, unreadCount);
+ chatConverter.toSendMessageResponse(chatRoomId, content, responseFiles, savedMessage, sender, profileImageUrl, unreadCount);
subscriptionService.broadcastMessage(chatRoomId, response, senderId);
log.info("메시지 브로드캐스트 완료 - 채팅방 ID: {}", chatRoomId);
@@ -97,30 +93,18 @@ public void sendSystemMessage(Long partyId, String content) {
}
// ========== 비즈니스 메서드 ==========
- private void attachFiles(ChatMessage message, List files) {
+ private void attachFiles(ChatMessage message, List files) {
if (files != null && !files.isEmpty()) {
files.forEach(fileInfo -> {
ChatMessageFile messageFile = ChatMessageFile.create(
- message, fileInfo.originalFileName(),
- fileInfo.fileKey(), fileInfo.fileSize(), fileInfo.fileType()
+ message, fileInfo.imgKey(), fileInfo.imgOrder(),
+ fileInfo.originalFileName(), fileInfo.fileSize(), fileInfo.fileType()
);
message.getChatMessageFiles().add(messageFile);
});
}
}
- private void attachImages(ChatMessage message, List images) {
- if (images != null && !images.isEmpty()) {
- images.forEach(imageInfo -> {
- ChatMessageImg messageImg = ChatMessageImg.create(
- message, imageInfo.imgKey(), imageInfo.imgOrder(),
- imageInfo.originalFileName(), imageInfo.fileSize(), imageInfo.fileType()
- );
- message.getChatMessageImgs().add(messageImg);
- });
- }
- }
-
private void checkFirstMessageInDirect(Long chatRoomId, Long senderId, ChatRoom chatRoom) {
if (chatRoom.getType() == ChatRoomType.DIRECT && isFirstMessage(chatRoomId)) {
handleFirstDirectMessage(chatRoomId, senderId);
@@ -144,26 +128,14 @@ private void handleFirstDirectMessage(Long chatRoomId, Long senderId) {
}
}
- private List createResponseImageInfos(
- List savedImages) {
- return savedImages.stream()
- .map(img -> ChatCommonDTO.ImageInfo.builder()
- .imageId(img.getId())
- .imageUrl(chatProcessor.generateImageUrl(img))
- .imgOrder(img.getImgOrder())
- .isEmoji(img.getIsEmoji())
- .originalFileName(img.getOriginalFileName())
- .fileSize(img.getFileSize())
- .fileType(img.getFileType())
- .build())
- .toList();
- }
-
- private List createResponseFileInfos(
+ private List createResponseFileInfos(
List savedFiles) {
return savedFiles.stream()
- .map(file -> WebSocketMessageDTO.MessageResponse.FileInfo.builder()
- .fileId(file.getId())
+ .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())
diff --git a/src/main/java/umc/cockple/demo/domain/contest/service/ContestCommandServiceImpl.java b/src/main/java/umc/cockple/demo/domain/contest/service/ContestCommandServiceImpl.java
index 56a9dcd30..d95e1ccaf 100644
--- a/src/main/java/umc/cockple/demo/domain/contest/service/ContestCommandServiceImpl.java
+++ b/src/main/java/umc/cockple/demo/domain/contest/service/ContestCommandServiceImpl.java
@@ -14,7 +14,7 @@
import umc.cockple.demo.domain.contest.repository.ContestRepository;
import umc.cockple.demo.domain.member.domain.Member;
import umc.cockple.demo.domain.member.repository.MemberRepository;
-import umc.cockple.demo.domain.image.service.ImageService;
+import umc.cockple.demo.domain.file.service.FileService;
import java.util.List;
import java.util.Map;
@@ -30,7 +30,7 @@ public class ContestCommandServiceImpl implements ContestCommandService {
private final ContestRepository contestRepository;
private final MemberRepository memberRepository;
private final ContestConverter contestConverter;
- private final ImageService imageService;
+ private final FileService fileService;
@@ -142,7 +142,7 @@ private void updateContestImages(Contest contest, List
.toList();
for (ContestImg img : imgsToRemove) {
- imageService.delete(img.getImgKey());
+ fileService.delete(img.getImgKey());
contest.getContestImgs().remove(img);
}
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 890390279..dd5ee0fd6 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
@@ -13,7 +13,7 @@
import umc.cockple.demo.domain.contest.exception.ContestException;
import umc.cockple.demo.domain.contest.repository.ContestRepository;
import umc.cockple.demo.domain.contest.enums.MedalType;
-import umc.cockple.demo.domain.image.service.ImageService;
+import umc.cockple.demo.domain.file.service.FileService;
import java.util.Comparator;
import java.util.List;
@@ -27,7 +27,7 @@ public class ContestQueryServiceImpl implements ContestQueryService {
private final ContestRepository contestRepository;
private final ContestConverter contestConverter;
- private final ImageService imageService;
+ private final FileService fileService;
// 대회 기록 상세 조회
@Override
@@ -106,7 +106,7 @@ private List getImageIds(Contest contest) {
private List getImageUrls(Contest contest) {
return contest.getContestImgs().stream()
.sorted(Comparator.comparing(ContestImg::getImgOrder))
- .map(img -> imageService.getUrlFromKey(img.getImgKey()))
+ .map(img -> fileService.getUrlFromKey(img.getImgKey()))
.collect(Collectors.toList());
}
@@ -147,6 +147,6 @@ public String getMedalImgUrl(Contest contest) {
case BRONZE -> baseKey + "3f9778a5-479a-44cf-bfb0-bea187a839c5.svg";
case NONE -> baseKey + "84e4dd20-7989-4871-954b-7363213b941e.svg";
};
- return imageService.getUrlFromKey(key);
+ return fileService.getUrlFromKey(key);
}
}
diff --git a/src/main/java/umc/cockple/demo/domain/exercise/AGENTS.md b/src/main/java/umc/cockple/demo/domain/exercise/AGENTS.md
new file mode 100644
index 000000000..90e916684
--- /dev/null
+++ b/src/main/java/umc/cockple/demo/domain/exercise/AGENTS.md
@@ -0,0 +1,27 @@
+# EXERCISE GUIDE
+
+Apply parent guides first. This file only covers `domain/exercise/`.
+
+## OVERVIEW
+Exercise is the densest feature slice: scheduling, guests, participation, waiting lists, recommendations, map/calendar queries, and the largest integration/service tests.
+
+## WHERE TO LOOK
+| Task | Location | Notes |
+|------|----------|-------|
+| Read-heavy hotspot | `service/ExerciseQueryService.java` | calendar/detail/recommendation/building/map queries |
+| Command internals | `service/command/internal/` | guest, lifecycle, participation subflows |
+| DTO mapping hotspot | `converter/ExerciseConverter.java` | very large conversion surface |
+| HTTP surface | `controller/ExerciseController.java` | CRUD + guests + calendars |
+| Error rules | `exception/ExerciseErrorCode.java` | started/past-time/permission constraints |
+| Integration coverage | `src/test/java/umc/cockple/demo/domain/exercise/` | biggest test package in repo |
+
+## CONVENTIONS
+- Time/date/location validation is centralized and reused through service methods and error codes.
+- Participation logic distinguishes confirmed participants from waiting members/guests.
+- Guest invitation rules depend on both party membership and exercise flags.
+- Converter growth is already high; new mapping code should stay tightly scoped to one flow.
+
+## ANTI-PATTERNS
+- Do not bypass `EXERCISE4xx` guardrails for past/start-state checks.
+- Do not duplicate participant/waiting-list logic in controllers or tests.
+- Do not spread unrelated conversions into `ExerciseConverter` without checking for an existing narrower path first.
diff --git a/src/main/java/umc/cockple/demo/domain/exercise/controller/ExerciseController.java b/src/main/java/umc/cockple/demo/domain/exercise/controller/ExerciseController.java
index 4bbbac6aa..2ef26b025 100644
--- a/src/main/java/umc/cockple/demo/domain/exercise/controller/ExerciseController.java
+++ b/src/main/java/umc/cockple/demo/domain/exercise/controller/ExerciseController.java
@@ -97,7 +97,7 @@ public ResponseEntity> updateExercise(
@ApiResponse(responseCode = "200", description = "운동 신청 성공")
@ApiResponse(responseCode = "400", description = "입력값 오류 또는 비즈니스 룰 위반")
@ApiResponse(responseCode = "403", description = "권한 없음, 급수 위반")
- public BaseResponse joinExercise(
+ public ResponseEntity> joinExercise(
@PathVariable Long exerciseId
) {
Long memberId = SecurityUtil.getCurrentMemberId();
@@ -105,7 +105,7 @@ public BaseResponse joinExercise(
ExerciseJoinDTO.Response response = exerciseCommandService.joinExercise(
exerciseId, memberId);
- return BaseResponse.success(CommonSuccessCode.CREATED, response);
+ return BaseResponse.of(CommonSuccessCode.CREATED, response);
}
@DeleteMapping("/exercises/{exerciseId}/participants/my")
@@ -114,7 +114,7 @@ public BaseResponse joinExercise(
@ApiResponse(responseCode = "200", description = "운동 참여 취소 성공")
@ApiResponse(responseCode = "400", description = "취소할 수 없는 상태 (이미 시작됨, 참여하지 않음 등)")
@ApiResponse(responseCode = "404", description = "운동 또는 참여 기록을 찾을 수 없음")
- public BaseResponse cancelParticipation(
+ public ResponseEntity> cancelParticipation(
@PathVariable Long exerciseId
) {
Long memberId = SecurityUtil.getCurrentMemberId();
@@ -122,7 +122,7 @@ public BaseResponse cancelParticipation(
ExerciseCancelDTO.Response response = exerciseCommandService.cancelParticipation(
exerciseId, memberId);
- return BaseResponse.success(CommonSuccessCode.OK, response);
+ return BaseResponse.of(CommonSuccessCode.OK, response);
}
@DeleteMapping("/exercises/{exerciseId}/participants/{participantId}")
@@ -151,7 +151,7 @@ public ResponseEntity> cancelParticipat
@ApiResponse(responseCode = "201", description = "게스트 초대 성공")
@ApiResponse(responseCode = "400", description = "입력값 오류 또는 비즈니스 룰 위반")
@ApiResponse(responseCode = "404", description = "운동을 찾을 수 없음")
- public BaseResponse inviteGuest(
+ public ResponseEntity> inviteGuest(
@PathVariable Long exerciseId,
@Valid @RequestBody ExerciseGuestInviteDTO.Request request
) {
@@ -160,7 +160,7 @@ public BaseResponse inviteGuest(
ExerciseGuestInviteDTO.Response response = exerciseCommandService.inviteGuest(
exerciseId, inviterId, request);
- return BaseResponse.success(CommonSuccessCode.CREATED, response);
+ return BaseResponse.of(CommonSuccessCode.CREATED, response);
}
@DeleteMapping("/exercises/{exerciseId}/guests/{guestId}")
@@ -170,7 +170,7 @@ public BaseResponse inviteGuest(
@ApiResponse(responseCode = "400", description = "취소할 수 없는 상태 (이미 시작됨)")
@ApiResponse(responseCode = "403", description = "본인이 초대한 게스트가 아닌 경우 취소할 수 없음")
@ApiResponse(responseCode = "404", description = "운동 또는 참여 기록을 찾을 수 없음")
- public BaseResponse cancelGuestInvitation(
+ public ResponseEntity> cancelGuestInvitation(
@PathVariable Long exerciseId,
@PathVariable Long guestId
) {
@@ -179,7 +179,7 @@ public BaseResponse cancelGuestInvitation(
ExerciseCancelDTO.Response response = exerciseCommandService.cancelGuestInvitation(
exerciseId, guestId, memberId);
- return BaseResponse.success(CommonSuccessCode.OK, response);
+ return BaseResponse.of(CommonSuccessCode.OK, response);
}
@GetMapping("/exercises/{exerciseId}")
diff --git a/src/main/java/umc/cockple/demo/domain/exercise/converter/ExerciseConverter.java b/src/main/java/umc/cockple/demo/domain/exercise/converter/ExerciseConverter.java
index 26d8340dd..c7a17cad8 100644
--- a/src/main/java/umc/cockple/demo/domain/exercise/converter/ExerciseConverter.java
+++ b/src/main/java/umc/cockple/demo/domain/exercise/converter/ExerciseConverter.java
@@ -8,7 +8,7 @@
import umc.cockple.demo.domain.exercise.domain.Guest;
import umc.cockple.demo.domain.exercise.dto.*;
import umc.cockple.demo.domain.exercise.enums.MyPartyExerciseOrderType;
-import umc.cockple.demo.domain.image.service.ImageService;
+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.domain.MemberExercise;
@@ -27,7 +27,7 @@
@RequiredArgsConstructor
public class ExerciseConverter {
- private final ImageService imageService;
+ private final FileService fileService;
// ========== Command 변환 메서드들 ==========
public ExerciseCreateDTO.Command toCreateCommand(ExerciseCreateDTO.Request request) {
@@ -496,14 +496,14 @@ private List filterExercisesByWeek(List exercises, LocalDate
private String getImageUrl(PartyImg partyImg) {
if (partyImg != null && partyImg.getImgKey() != null && !partyImg.getImgKey().isBlank()) {
- return imageService.getUrlFromKey(partyImg.getImgKey());
+ return fileService.getUrlFromKey(partyImg.getImgKey());
}
return null;
}
private String getImageUrl(ProfileImg profileImg) {
if (profileImg != null && profileImg.getImgKey() != null && !profileImg.getImgKey().isBlank()) {
- return imageService.getUrlFromKey(profileImg.getImgKey());
+ return fileService.getUrlFromKey(profileImg.getImgKey());
}
return null;
}
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
index 6876cee7a..adf7ae4b5 100644
--- a/src/main/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryService.java
+++ b/src/main/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryService.java
@@ -391,7 +391,7 @@ private void validatePartyIsActive(Party party) {
private boolean checkManagerPermission(Party party, Member member) {
return memberPartyRepository.existsByPartyIdAndMemberIdAndRole(
- party.getId(), member.getId(), Role.party_MANAGER);
+ party.getId(), member.getId(), Role.PARTY_MANAGER);
}
private ExerciseDetailDTO.ExerciseInfo createExerciseInfo(Exercise exercise) {
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 a81174b3f..fabda2ec8 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
@@ -90,9 +90,9 @@ private void validatePartyIsActive(Party party) {
private void validateSubManagerPermission(Long memberId, Party party) {
boolean isOwner = party.getOwnerId().equals(memberId);
boolean isManager = memberPartyRepository.existsByPartyIdAndMemberIdAndRole(
- party.getId(), memberId, Role.party_MANAGER);
+ party.getId(), memberId, Role.PARTY_MANAGER);
boolean isSubManager = memberPartyRepository.existsByPartyIdAndMemberIdAndRole(
- party.getId(), memberId, Role.party_SUBMANAGER);
+ party.getId(), memberId, Role.PARTY_SUBMANAGER);
if (!isOwner && !isManager && !isSubManager)
throw new ExerciseException(ExerciseErrorCode.INSUFFICIENT_PERMISSION);
diff --git a/src/main/java/umc/cockple/demo/domain/exercise/service/command/internal/ExerciseGuestService.java b/src/main/java/umc/cockple/demo/domain/exercise/service/command/internal/ExerciseGuestService.java
index 16a6e9baf..268a58a73 100644
--- a/src/main/java/umc/cockple/demo/domain/exercise/service/command/internal/ExerciseGuestService.java
+++ b/src/main/java/umc/cockple/demo/domain/exercise/service/command/internal/ExerciseGuestService.java
@@ -13,7 +13,6 @@
import umc.cockple.demo.domain.exercise.repository.GuestRepository;
import umc.cockple.demo.domain.exercise.service.ExerciseValidator;
import umc.cockple.demo.domain.member.domain.Member;
-import umc.cockple.demo.domain.member.repository.MemberRepository;
@Service
@Transactional
@@ -22,7 +21,6 @@
public class ExerciseGuestService {
private final ExerciseRepository exerciseRepository;
- private final MemberRepository memberRepository;
private final GuestRepository guestRepository;
private final ExerciseValidator exerciseValidator;
@@ -40,7 +38,6 @@ public ExerciseGuestInviteDTO.Response inviteGuest(Exercise exercise, Member inv
Guest savedGuest = guestRepository.save(guest);
log.info("게스트 초대 완료 - guestId: {}", savedGuest.getId());
-
return exerciseConverter.toGuestInviteResponse(savedGuest, exercise);
}
@@ -48,13 +45,11 @@ public ExerciseCancelDTO.Response cancelGuestInvitation(Exercise exercise, Guest
exerciseValidator.validateCancelGuestInvitation(exercise, guest, member);
exercise.removeGuest(guest);
-
guestRepository.delete(guest);
exerciseRepository.save(exercise);
log.info("게스트 초대 취소 완료 - exerciseId: {}, guestId: {}, memberId: {}", exercise.getId(), guest.getId(), member.getId());
-
return exerciseConverter.toCancelResponse(exercise, guest);
}
}
diff --git a/src/main/java/umc/cockple/demo/domain/file/controller/FileController.java b/src/main/java/umc/cockple/demo/domain/file/controller/FileController.java
new file mode 100644
index 000000000..ce0d28761
--- /dev/null
+++ b/src/main/java/umc/cockple/demo/domain/file/controller/FileController.java
@@ -0,0 +1,42 @@
+package umc.cockple.demo.domain.file.controller;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.MediaType;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+import umc.cockple.demo.domain.file.dto.FileUploadDTO;
+import umc.cockple.demo.domain.file.service.FileService;
+import umc.cockple.demo.global.enums.DomainType;
+import umc.cockple.demo.global.response.BaseResponse;
+import umc.cockple.demo.global.response.code.status.CommonSuccessCode;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/api")
+@RequiredArgsConstructor
+@Validated
+@Tag(name = "File", description = "파일 업로드 API")
+public class FileController {
+
+ private final FileService fileService;
+
+ @PostMapping(value = "/gcs/upload/file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+ @Operation(summary = "파일 업로드", description = "GCS에 파일을 업로드하고 파일 URL과 fileKey를 반환합니다.")
+ public BaseResponse fileUpload(@RequestPart("file") MultipartFile file,
+ @RequestParam("domainType") DomainType domainType) {
+
+ return BaseResponse.success(CommonSuccessCode.ACCEPTED, fileService.uploadFile(file, domainType));
+ }
+
+ @PostMapping(value = "/gcs/upload/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+ @Operation(summary = "파일 여러장 업로드", description = "GCS에 파일 여러장을 업로드하고 파일 URL과 fileKey를 반환합니다.")
+ public BaseResponse> fileUpload(@RequestPart("file") List files,
+ @RequestParam("domainType") DomainType domainType) {
+
+ return BaseResponse.success(CommonSuccessCode.ACCEPTED, fileService.uploadFiles(files, domainType));
+ }
+}
diff --git a/src/main/java/umc/cockple/demo/domain/image/dto/FileUploadDTO.java b/src/main/java/umc/cockple/demo/domain/file/dto/FileUploadDTO.java
similarity index 85%
rename from src/main/java/umc/cockple/demo/domain/image/dto/FileUploadDTO.java
rename to src/main/java/umc/cockple/demo/domain/file/dto/FileUploadDTO.java
index c6df9a8e2..7159e6bf5 100644
--- a/src/main/java/umc/cockple/demo/domain/image/dto/FileUploadDTO.java
+++ b/src/main/java/umc/cockple/demo/domain/file/dto/FileUploadDTO.java
@@ -1,4 +1,4 @@
-package umc.cockple.demo.domain.image.dto;
+package umc.cockple.demo.domain.file.dto;
import lombok.Builder;
diff --git a/src/main/java/umc/cockple/demo/domain/file/exception/GcsErrorCode.java b/src/main/java/umc/cockple/demo/domain/file/exception/GcsErrorCode.java
new file mode 100644
index 000000000..f8c45bc5e
--- /dev/null
+++ b/src/main/java/umc/cockple/demo/domain/file/exception/GcsErrorCode.java
@@ -0,0 +1,34 @@
+package umc.cockple.demo.domain.file.exception;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import umc.cockple.demo.global.response.code.BaseErrorCode;
+import umc.cockple.demo.global.response.dto.ErrorReasonDTO;
+
+@Getter
+@RequiredArgsConstructor
+public enum GcsErrorCode implements BaseErrorCode {
+
+ /**
+ * 1xx: 클라이언트가 수정해야 할 입력값 문제
+ * 2xx: 서버에서 리소스를 찾을 수 없는 문제
+ * 3xx: 권한/인증 문제
+ * 4xx: 비즈니스 로직 위반
+ */
+ FILE_UPLOAD_GCS_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "FILE501", "파일 업로드 중 GCS 예외가 발생하였습니다. 서버 관리자에게 문의해주세요."),
+ FILE_UPLOAD_IO_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "FILE502", "파일 업로드 중 IO 예외가 발생하였습니다. 서버 관리자에게 문의해주세요."),
+ FILE_STILL_EXIST(HttpStatus.INTERNAL_SERVER_ERROR,"FILE503" ,"파일이 삭제되지 않고 GCS에 남아있습니다. 서버 관리자에게 문의해주세요." ),
+ FILE_DELETE_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR,"FILE504" ,"파일 삭제에 실패하였습니다. 서버 관리자에게 문의해주세요." ),
+ FILE_BUCKET_DIRECTORY_NULL(HttpStatus.BAD_REQUEST, "FILE505", "버킷 디렉토리 값이 유효하지 않습니다.");
+ ;
+
+ private final HttpStatus httpStatus;
+ private final String code;
+ private final String message;
+
+ @Override
+ public ErrorReasonDTO getReason() {
+ return ErrorReasonDTO.of(code, message, httpStatus);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/umc/cockple/demo/domain/file/exception/GcsException.java b/src/main/java/umc/cockple/demo/domain/file/exception/GcsException.java
new file mode 100644
index 000000000..11e8672b5
--- /dev/null
+++ b/src/main/java/umc/cockple/demo/domain/file/exception/GcsException.java
@@ -0,0 +1,9 @@
+package umc.cockple.demo.domain.file.exception;
+
+import umc.cockple.demo.global.exception.GeneralException;
+import umc.cockple.demo.global.response.code.BaseErrorCode;
+
+public class GcsException extends GeneralException {
+
+ public GcsException(BaseErrorCode code) { super(code); }
+}
diff --git a/src/main/java/umc/cockple/demo/domain/image/service/ImageService.java b/src/main/java/umc/cockple/demo/domain/file/service/FileService.java
similarity index 61%
rename from src/main/java/umc/cockple/demo/domain/image/service/ImageService.java
rename to src/main/java/umc/cockple/demo/domain/file/service/FileService.java
index ff74504c5..1bd7121fc 100644
--- a/src/main/java/umc/cockple/demo/domain/image/service/ImageService.java
+++ b/src/main/java/umc/cockple/demo/domain/file/service/FileService.java
@@ -1,132 +1,110 @@
-package umc.cockple.demo.domain.image.service;
-
-import com.google.cloud.storage.*;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.beans.factory.annotation.Value;
-import org.springframework.stereotype.Service;
-import org.springframework.util.StringUtils;
-import org.springframework.web.multipart.MultipartFile;
-import umc.cockple.demo.domain.image.dto.FileUploadDTO;
-import umc.cockple.demo.domain.image.dto.ImageUploadDTO;
-import umc.cockple.demo.domain.image.exception.S3ErrorCode;
-import umc.cockple.demo.domain.image.exception.S3Exception;
-import umc.cockple.demo.global.enums.DomainType;
-
-import java.io.IOException;
-import java.util.List;
-import java.util.UUID;
-import java.util.stream.Collectors;
-
-@Service
-@RequiredArgsConstructor
-@Slf4j
-public class ImageService {
-
- @Value("${gcs.bucket}")
- private String bucket;
-
- private final Storage storage;
-
- public ImageUploadDTO.Response uploadImage(MultipartFile image, DomainType domainType) {
- if (image == null || image.isEmpty()) {
- return null;
- }
-
- log.info("[이미지 업로드 시작]");
-
- String originalFileName = image.getOriginalFilename();
- String key = getFileKey(image, domainType);
- String imgUrl = uploadToGcs(image, key);
-
- log.info("[이미지 업로드 완료]");
- return ImageUploadDTO.Response.builder()
- .imgUrl(imgUrl)
- .imgKey(key)
- .originalFileName(originalFileName)
- .fileSize(image.getSize())
- .fileType(image.getContentType())
- .build();
- }
-
- public FileUploadDTO.Response uploadFile(MultipartFile file, DomainType domainType) {
- if (file == null || file.isEmpty()) {
- return null;
- }
-
- log.info("[파일 업로드 시작]");
-
- String originalFileName = file.getOriginalFilename();
- String key = getFileKey(file, domainType);
- String fileUrl = uploadToGcs(file, key);
-
- log.info("[파일 업로드 완료]");
- return FileUploadDTO.Response.builder()
- .fileKey(key)
- .fileUrl(fileUrl)
- .originalFileName(originalFileName)
- .fileSize(file.getSize())
- .fileType(file.getContentType())
- .build();
- }
-
- public List uploadImages(List images, DomainType domainType) {
- if (images == null || images.isEmpty()) {
- return List.of();
- }
-
- return images.stream()
- .map(img -> uploadImage(img, domainType))
- .collect(Collectors.toList());
- }
-
- public void delete(String imgKey) {
- try {
- storage.delete(BlobId.of(bucket, imgKey));
- log.info("[GCS 삭제 성공] {}", imgKey);
- } catch (Exception e) {
- log.error("[GCS 삭제 실패] {}", e.getMessage());
- throw new S3Exception(S3ErrorCode.IMAGE_DELETE_EXCEPTION);
- }
- }
-
- private String uploadToGcs(MultipartFile file, String key) {
- try {
- BlobInfo blobInfo = BlobInfo.newBuilder(bucket, key)
- .setContentType(file.getContentType())
- .build();
- storage.create(blobInfo, file.getBytes());
- return String.format("https://storage.googleapis.com/%s/%s", bucket, key);
- } catch (IOException e) {
- log.error("[GCS 업로드 실패 - IO 예외] {}", e.getMessage());
- throw new S3Exception(S3ErrorCode.FILE_UPLOAD_IO_EXCEPTION);
- } catch (StorageException e) {
- log.error("[GCS 업로드 실패 - Storage 예외] {}", e.getMessage());
- throw new S3Exception(S3ErrorCode.FILE_UPLOAD_AMAZON_EXCEPTION);
- }
- }
-
- public String getFileKey(MultipartFile file, DomainType domainType) {
- if (file == null || file.isEmpty()) {
- return null;
- }
-
- String originalFilename = file.getOriginalFilename();
- String extension = StringUtils.getFilenameExtension(originalFilename);
- String uuid = UUID.randomUUID().toString();
-
- return domainType.getDirectory() + "/" + uuid + "." + extension;
- }
-
- public String getUrlFromKey(String key) {
- return String.format("https://storage.googleapis.com/%s/%s", bucket, key);
- }
-
- public Blob downloadFile(String fileKey) {
- Blob blob = storage.get(BlobId.of(bucket, fileKey));
- if (blob == null) {
- throw new S3Exception(S3ErrorCode.IMAGE_DELETE_EXCEPTION);
- }
- return blob;
- }
+package umc.cockple.demo.domain.file.service;
+
+import com.google.cloud.storage.*;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+import org.springframework.web.multipart.MultipartFile;
+import umc.cockple.demo.domain.file.dto.FileUploadDTO;
+import umc.cockple.demo.domain.file.exception.GcsErrorCode;
+import umc.cockple.demo.domain.file.exception.GcsException;
+import umc.cockple.demo.global.enums.DomainType;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.UUID;
+import java.util.stream.Collectors;
+
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class FileService {
+
+ @Value("${gcs.bucket}")
+ private String bucket;
+
+ private final Storage storage;
+
+ public FileUploadDTO.Response uploadFile(MultipartFile file, DomainType domainType) {
+ if (file == null || file.isEmpty()) {
+ return null;
+ }
+
+ log.info("[파일 업로드 시작]");
+
+ String originalFileName = file.getOriginalFilename();
+ String key = getFileKey(file, domainType);
+ String fileUrl = uploadToGcs(file, key);
+
+ log.info("[파일 업로드 완료]");
+ return FileUploadDTO.Response.builder()
+ .fileKey(key)
+ .fileUrl(fileUrl)
+ .originalFileName(originalFileName)
+ .fileSize(file.getSize())
+ .fileType(file.getContentType())
+ .build();
+ }
+
+ public List uploadFiles(List files, DomainType domainType) {
+ if (files == null || files.isEmpty()) {
+ return List.of();
+ }
+
+ return files.stream()
+ .map(file -> uploadFile(file, domainType))
+ .collect(Collectors.toList());
+ }
+
+ public void delete(String fileKey) {
+ try {
+ storage.delete(BlobId.of(bucket, fileKey));
+ log.info("[GCS 삭제 성공] {}", fileKey);
+ } catch (Exception e) {
+ log.error("[GCS 삭제 실패] {}", e.getMessage());
+ throw new GcsException(GcsErrorCode.FILE_DELETE_EXCEPTION);
+ }
+ }
+
+ private String uploadToGcs(MultipartFile file, String key) {
+ try {
+ BlobInfo blobInfo = BlobInfo.newBuilder(bucket, key)
+ .setContentType(file.getContentType())
+ .build();
+ storage.create(blobInfo, file.getBytes());
+ return String.format("https://storage.googleapis.com/%s/%s", bucket, key);
+ } catch (IOException e) {
+ log.error("[GCS 업로드 실패 - IO 예외] {}", e.getMessage());
+ throw new GcsException(GcsErrorCode.FILE_UPLOAD_IO_EXCEPTION);
+ } catch (StorageException e) {
+ log.error("[GCS 업로드 실패 - Storage 예외] {}", e.getMessage());
+ throw new GcsException(GcsErrorCode.FILE_UPLOAD_GCS_EXCEPTION);
+ }
+ }
+
+ public String getFileKey(MultipartFile file, DomainType domainType) {
+ if (file == null || file.isEmpty()) {
+ return null;
+ }
+
+ String originalFilename = file.getOriginalFilename();
+ String extension = StringUtils.getFilenameExtension(originalFilename);
+ String uuid = UUID.randomUUID().toString();
+
+ return domainType.getDirectory() + "/" + uuid + "." + extension;
+ }
+
+ public String getUrlFromKey(String key) {
+ return String.format("https://storage.googleapis.com/%s/%s", bucket, key);
+ }
+
+ public Blob downloadFile(String fileKey) {
+ Blob blob = storage.get(BlobId.of(bucket, fileKey));
+ if (blob == null) {
+ throw new GcsException(GcsErrorCode.FILE_DELETE_EXCEPTION);
+ }
+ return blob;
+ }
}
\ No newline at end of file
diff --git a/src/main/java/umc/cockple/demo/domain/image/controller/ImgController.java b/src/main/java/umc/cockple/demo/domain/image/controller/ImgController.java
deleted file mode 100644
index aef8c61c7..000000000
--- a/src/main/java/umc/cockple/demo/domain/image/controller/ImgController.java
+++ /dev/null
@@ -1,52 +0,0 @@
-package umc.cockple.demo.domain.image.controller;
-
-import io.swagger.v3.oas.annotations.Operation;
-import io.swagger.v3.oas.annotations.tags.Tag;
-import lombok.RequiredArgsConstructor;
-import org.springframework.http.MediaType;
-import org.springframework.validation.annotation.Validated;
-import org.springframework.web.bind.annotation.*;
-import org.springframework.web.multipart.MultipartFile;
-import umc.cockple.demo.domain.image.dto.FileUploadDTO;
-import umc.cockple.demo.domain.image.dto.ImageUploadDTO;
-import umc.cockple.demo.domain.image.service.ImageService;
-import umc.cockple.demo.global.enums.DomainType;
-import umc.cockple.demo.global.response.BaseResponse;
-import umc.cockple.demo.global.response.code.status.CommonSuccessCode;
-
-import java.util.List;
-
-@RestController
-@RequestMapping("/api")
-@RequiredArgsConstructor
-@Validated
-@Tag(name = "Image", description = "이미지 API")
-public class ImgController {
-
- private final ImageService imageService;
-
- @PostMapping(value = "/s3/upload/img", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
- @Operation(summary = "이미지 업로드", description = "GCS에 이미지를 업로드하고 이미지 URL과 imgKey를 반환합니다.")
- public BaseResponse imgUpload(@RequestPart("image") MultipartFile image,
- @RequestParam("domainType") DomainType domainType) {
-
- return BaseResponse.success(CommonSuccessCode.ACCEPTED, imageService.uploadImage(image, domainType));
- }
-
-
- @PostMapping(value = "/s3/upload/imgs", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
- @Operation(summary = "이미지 여러장 업로드", description = "GCS에 이미지 여러장을 업로드하고 이미지 URL과 imgKey를 반환합니다.")
- public BaseResponse> imgUpload(@RequestPart("image") List images,
- @RequestParam("domainType") DomainType domainType) {
-
- return BaseResponse.success(CommonSuccessCode.ACCEPTED, imageService.uploadImages(images, domainType));
- }
-
- @PostMapping(value = "/s3/upload/file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
- @Operation(summary = "파일 업로드", description = "GCS에 파일을 업로드하고 파일정보를 반환합니다.")
- public BaseResponse fileUpload(@RequestPart("file") MultipartFile file,
- @RequestParam("domainType") DomainType domainType) {
-
- return BaseResponse.success(CommonSuccessCode.ACCEPTED, imageService.uploadFile(file, domainType));
- }
-}
diff --git a/src/main/java/umc/cockple/demo/domain/image/dto/ImageUploadDTO.java b/src/main/java/umc/cockple/demo/domain/image/dto/ImageUploadDTO.java
deleted file mode 100644
index 1d9844338..000000000
--- a/src/main/java/umc/cockple/demo/domain/image/dto/ImageUploadDTO.java
+++ /dev/null
@@ -1,14 +0,0 @@
-package umc.cockple.demo.domain.image.dto;
-
-import lombok.Builder;
-
-public class ImageUploadDTO{
- @Builder
- public record Response(
- String imgUrl,
- String imgKey,
- String originalFileName,
- Long fileSize,
- String fileType
- ) {}
-}
diff --git a/src/main/java/umc/cockple/demo/domain/image/exception/S3ErrorCode.java b/src/main/java/umc/cockple/demo/domain/image/exception/S3ErrorCode.java
deleted file mode 100644
index 751a48a5f..000000000
--- a/src/main/java/umc/cockple/demo/domain/image/exception/S3ErrorCode.java
+++ /dev/null
@@ -1,36 +0,0 @@
-package umc.cockple.demo.domain.image.exception;
-
-import lombok.Getter;
-import lombok.RequiredArgsConstructor;
-import org.springframework.http.HttpStatus;
-import umc.cockple.demo.global.response.code.BaseErrorCode;
-import umc.cockple.demo.global.response.dto.ErrorReasonDTO;
-
-@Getter
-@RequiredArgsConstructor
-public enum S3ErrorCode implements BaseErrorCode {
-
- /**
- * 1xx: 클라이언트가 수정해야 할 입력값 문제
- * 2xx: 서버에서 리소스를 찾을 수 없는 문제
- * 3xx: 권한/인증 문제
- * 4xx: 비즈니스 로직 위반
- */
- IMAGE_UPLOAD_AMAZON_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "IMG501", "이미지 업로드 중, AWS 예외가 발생하였습니다. 서버 관리자에게 문의해주세요"),
- IMAGE_UPLOAD_IO_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "IMG502", "이미지 업로드 중, IO 예외가 발생하였습니다. 서버 관리자에게 문의해주세요"),
- IMAGE_STILL_EXIST(HttpStatus.INTERNAL_SERVER_ERROR,"IMG503" ,"이미지가 삭제되지 않고 S3에 남아있습니다. 서버 관리자에게 문의해주세요" ),
- IMAGE_DELETE_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR,"IMG504" ,"이미지 삭제에 실패하였습니다. 서버관리자에게 문의해주세요" ),
- IMAGE_BUCKET_DIRECTORY_NULL(HttpStatus.BAD_REQUEST, "IMG505", "버킷 디렉토리 값이 유효하지 않습니다."),
- FILE_UPLOAD_AMAZON_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "IMG506", "파일 업로드 중, AWS 예외가 발생하였습니다. 서버 관리자에게 문의해주세요"),
- FILE_UPLOAD_IO_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "IMG507", "파일 업로드 중, IO 예외가 발생하였습니다. 서버 관리자에게 문의해주세요"),
- ;
-
- private final HttpStatus httpStatus;
- private final String code;
- private final String message;
-
- @Override
- public ErrorReasonDTO getReason() {
- return ErrorReasonDTO.of(code, message, httpStatus);
- }
-}
\ No newline at end of file
diff --git a/src/main/java/umc/cockple/demo/domain/image/exception/S3Exception.java b/src/main/java/umc/cockple/demo/domain/image/exception/S3Exception.java
deleted file mode 100644
index aefb75ce9..000000000
--- a/src/main/java/umc/cockple/demo/domain/image/exception/S3Exception.java
+++ /dev/null
@@ -1,9 +0,0 @@
-package umc.cockple.demo.domain.image.exception;
-
-import umc.cockple.demo.global.exception.GeneralException;
-import umc.cockple.demo.global.response.code.BaseErrorCode;
-
-public class S3Exception extends GeneralException {
-
- public S3Exception(BaseErrorCode code) { super(code); }
-}
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 7e4bd68b4..55d1150d0 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
@@ -53,6 +53,7 @@ public ResponseEntity login(@RequestBody @Valid KakaoLogi
.path("/")
.maxAge(Duration.ofDays(7))
.sameSite("None")
+ .domain(".cockple.store")
.build()
;
@@ -90,7 +91,7 @@ public BaseResponse issueOtherDevToken() {
@PostMapping("/my/details")
@Operation(summary = "로그인 후 상세 정보 받기 API",
description = "로그인 후 추가적인 상세 정보를 받습니다.")
- public BaseResponse memberDetailInfo(@RequestBody @Valid MemberDetailInfoRequestDTO requestDTO) {
+ public BaseResponse registerMemberDetailInfo(@RequestBody @Valid MemberDetailInfoRequestDTO requestDTO) {
Long memberId = SecurityUtil.getCurrentMemberId();
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 041d34b9e..1b5456858 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
@@ -56,6 +56,7 @@ public class Member extends BaseEntity {
@Column(nullable = false)
private Long socialId; // 카카오에서 받아온 고유id
+ private String fcmToken;
@OneToMany(mappedBy = "member", cascade = CascadeType.ALL)
@Builder.Default
@@ -175,12 +176,18 @@ public boolean hasDuplicateAddr(CreateMemberAddrRequestDTO requestDTO) {
public void withdraw() {
this.isActive = MemberStatus.INACTIVE;
this.refreshToken = null;
+ this.fcmToken = null;
}
public void setRefreshToken(String refreshToken) {
this.refreshToken = refreshToken;
}
+ // FCM 토큰 업데이트 메서드
+ public void updateFcmToken(String fcmToken) {
+ this.fcmToken = fcmToken;
+ }
+
public void rejoin() {
this.isActive = MemberStatus.ACTIVE;
initField();
diff --git a/src/main/java/umc/cockple/demo/domain/member/domain/MemberParty.java b/src/main/java/umc/cockple/demo/domain/member/domain/MemberParty.java
index 8d3cc6794..cf85ec7cd 100644
--- a/src/main/java/umc/cockple/demo/domain/member/domain/MemberParty.java
+++ b/src/main/java/umc/cockple/demo/domain/member/domain/MemberParty.java
@@ -47,7 +47,7 @@ public static MemberParty createOwner(Member member, Party party) {
return MemberParty.builder()
.member(member)
.party(party)
- .role(Role.party_MANAGER)
+ .role(Role.PARTY_MANAGER)
.joinedAt(LocalDateTime.now())
.status(ACTIVE)
.build();
@@ -57,20 +57,20 @@ public static MemberParty create(Party party, Member member) {
return MemberParty.builder()
.member(member)
.party(party)
- .role(Role.party_MEMBER)
+ .role(Role.PARTY_MEMBER)
.joinedAt(LocalDateTime.now())
.status(ACTIVE)
.build();
}
public boolean isLeader() {
- if (this.role == Role.party_MANAGER) return true;
+ if (this.role == Role.PARTY_MANAGER) return true;
return false;
}
public boolean isViceLeader() {
- if (this.role == Role.party_SUBMANAGER) return true;
+ if (this.role == Role.PARTY_SUBMANAGER) return true;
return false;
}
diff --git a/src/main/java/umc/cockple/demo/domain/member/dto/GetProfileResponseDTO.java b/src/main/java/umc/cockple/demo/domain/member/dto/GetProfileResponseDTO.java
index 77bfa1dec..8175f32c2 100644
--- a/src/main/java/umc/cockple/demo/domain/member/dto/GetProfileResponseDTO.java
+++ b/src/main/java/umc/cockple/demo/domain/member/dto/GetProfileResponseDTO.java
@@ -1,26 +1,26 @@
-package umc.cockple.demo.domain.member.dto;
-
-import jakarta.validation.constraints.NotBlank;
-import lombok.Builder;
-import umc.cockple.demo.domain.member.domain.MemberKeyword;
-import umc.cockple.demo.global.enums.Gender;
-import umc.cockple.demo.global.enums.Keyword;
-import umc.cockple.demo.global.enums.Level;
-
-import java.time.LocalDate;
-import java.util.List;
-
-@Builder
-public record GetProfileResponseDTO(
- String memberName,
- LocalDate birth,
- Gender gender,
- Level level,
- String profileImgUrl,
- Integer myPartyCnt,
- Integer myGoldMedalCnt,
- Integer mySilverMedalCnt,
- Integer myBronzeMedalCnt
-
-) {
-}
+package umc.cockple.demo.domain.member.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import lombok.Builder;
+import umc.cockple.demo.domain.member.domain.MemberKeyword;
+import umc.cockple.demo.global.enums.Gender;
+import umc.cockple.demo.global.enums.Keyword;
+import umc.cockple.demo.global.enums.Level;
+
+import java.time.LocalDate;
+import java.util.List;
+
+@Builder
+public record GetProfileResponseDTO(
+ String memberName,
+ LocalDate birth,
+ Gender gender,
+ Level level,
+ String profileImgUrl,
+ Integer myPartyCnt,
+ Integer myGoldMedalCnt,
+ Integer mySilverMedalCnt,
+ Integer myBronzeMedalCnt
+
+) {
+}
diff --git a/src/main/java/umc/cockple/demo/domain/member/repository/MemberKeywordRepository.java b/src/main/java/umc/cockple/demo/domain/member/repository/MemberKeywordRepository.java
index 8c772d55b..930259ffb 100644
--- a/src/main/java/umc/cockple/demo/domain/member/repository/MemberKeywordRepository.java
+++ b/src/main/java/umc/cockple/demo/domain/member/repository/MemberKeywordRepository.java
@@ -4,7 +4,11 @@
import umc.cockple.demo.domain.member.domain.Member;
import umc.cockple.demo.domain.member.domain.MemberKeyword;
+import java.util.List;
+
public interface MemberKeywordRepository extends JpaRepository {
void deleteAllByMember(Member member);
+
+ List findAllByMemberId(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 c1d0a0c7a..7457c6020 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
@@ -5,7 +5,6 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
-import umc.cockple.demo.domain.chat.domain.ChatRoomMember;
import umc.cockple.demo.domain.chat.repository.ChatRoomMemberRepository;
import umc.cockple.demo.domain.member.domain.*;
import umc.cockple.demo.domain.member.dto.MemberDetailInfoRequestDTO;
@@ -15,7 +14,7 @@
import umc.cockple.demo.domain.member.exception.MemberException;
import umc.cockple.demo.domain.member.repository.*;
import umc.cockple.demo.domain.member.enums.MemberStatus;
-import umc.cockple.demo.domain.image.service.ImageService;
+import umc.cockple.demo.domain.file.service.FileService;
import java.time.LocalDate;
import java.time.LocalTime;
@@ -38,7 +37,7 @@ public class MemberCommandService {
private final ChatRoomMemberRepository chatRoomMemberRepository;
private final KakaoOauthService kakaoOauthService;
- private final ImageService imageService;
+ private final FileService fileService;
// ==================== 회원 관련 ===================
@@ -138,7 +137,7 @@ public void updateProfile(UpdateProfileRequestDTO requestDto, Long memberId) {
// 프로필 사진이 변경되었을 경우에만 이미지 url 변경 및 S3 사진 변경
if (!profile.getImgKey().equals(imgKey)) {
- imageService.delete(profile.getImgKey());
+ fileService.delete(profile.getImgKey());
profile.updateProfile(imgKey);
}
@@ -158,9 +157,8 @@ public void updateProfile(UpdateProfileRequestDTO requestDto, Long memberId) {
}
}
- //chatRoomMember의 displayName도 같이 업데이트
- List chatRoomMembers = chatRoomMemberRepository.findAllByMemberId(member.getId());
- chatRoomMembers.forEach(crm -> crm.updateDisplayName(requestDto.memberName()));
+ chatRoomMemberRepository.findDirectChatCounterParts(member.getId())
+ .forEach(crm -> crm.updateDisplayName(requestDto.memberName()));
}
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 b140f1a56..a8c812574 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
@@ -7,7 +7,7 @@
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.image.service.ImageService;
+import umc.cockple.demo.domain.file.service.FileService;
import umc.cockple.demo.domain.member.converter.MemberConverter;
import umc.cockple.demo.domain.member.domain.Member;
import umc.cockple.demo.domain.member.domain.MemberAddr;
@@ -34,7 +34,7 @@
public class MemberQueryService {
private final MemberRepository memberRepository;
- private final ImageService imageService;
+ private final FileService fileService;
/*
* 프로필 관련 조회 메서드
@@ -69,7 +69,7 @@ public GetProfileResponseDTO getProfile(Long memberId) {
// 프로필 사진 null-safety
String imgUrl = null;
if (member.getProfileImg() != null) {
- imgUrl = imageService.getUrlFromKey(member.getProfileImg().getImgKey());
+ imgUrl = fileService.getUrlFromKey(member.getProfileImg().getImgKey());
}
// 각 메달 개수 카운트
diff --git a/src/main/java/umc/cockple/demo/domain/notification/controller/NotificationController.java b/src/main/java/umc/cockple/demo/domain/notification/controller/NotificationController.java
index fb6a5956a..3760956a7 100644
--- a/src/main/java/umc/cockple/demo/domain/notification/controller/NotificationController.java
+++ b/src/main/java/umc/cockple/demo/domain/notification/controller/NotificationController.java
@@ -1,64 +1,80 @@
-package umc.cockple.demo.domain.notification.controller;
-
-import io.swagger.v3.oas.annotations.Operation;
-import io.swagger.v3.oas.annotations.tags.Tag;
-import lombok.RequiredArgsConstructor;
-import org.springframework.validation.annotation.Validated;
-import org.springframework.web.bind.annotation.*;
-import umc.cockple.demo.domain.notification.dto.AllNotificationsResponseDTO;
-import umc.cockple.demo.domain.notification.service.NotificationCommandService;
-import umc.cockple.demo.domain.notification.dto.ExistNewNotificationResponseDTO;
-import umc.cockple.demo.domain.notification.service.NotificationQueryService;
-import umc.cockple.demo.global.response.BaseResponse;
-import umc.cockple.demo.global.response.code.status.CommonSuccessCode;
-import umc.cockple.demo.global.security.utils.SecurityUtil;
-
-import java.util.List;
-
-import static umc.cockple.demo.domain.notification.dto.MarkAsReadDTO.*;
-
-@RestController
-@RequestMapping("/api")
-@RequiredArgsConstructor
-@Validated
-@Tag(name = "Notification", description = "알림 API")
-public class NotificationController {
-
- private final NotificationQueryService notificationQueryService;
- private final NotificationCommandService notificationCommandService;
-
- @GetMapping("/notifications")
- @Operation(summary = "내 알림 전체 조회",
- description = "사용자에게 온 알림 전체를 조회합니다. ")
- public BaseResponse> getAllNotifications() {
-
- Long memberId = SecurityUtil.getCurrentMemberId();
-
- return BaseResponse.success(CommonSuccessCode.OK, notificationQueryService.getAllNotifications(memberId));
- }
-
-
-
- @GetMapping("/notifications/count")
- @Operation(summary = "안 읽은 알림 존재여부 조회",
- description = "사용자가 읽지 않은 알림이 있는지 확인합니다. 존재 시 알림 아이콘에 빨간 점이 표시됩니다 ")
- public BaseResponse checkUnReadNotification() {
-
- Long memberId = SecurityUtil.getCurrentMemberId();
-
- return BaseResponse.success(CommonSuccessCode.OK,notificationQueryService.checkUnreadNotification(memberId));
- }
-
-
-
- @PatchMapping("/notifications/{notificationId}")
- @Operation(summary = "내 특정 알림 조회 및 읽음 처리",
- description = "특정 알림을 조회하고 읽음 처리를 진행합니다. ")
- public BaseResponse markAsReadNotification(@PathVariable Long notificationId,
- Request type) {
- Long memberId = SecurityUtil.getCurrentMemberId();
-
- return BaseResponse.success(CommonSuccessCode.OK, notificationCommandService.markAsReadNotification(memberId, notificationId, type.type()));
- }
-
-}
+package umc.cockple.demo.domain.notification.controller;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+import umc.cockple.demo.domain.notification.dto.AllNotificationsResponseDTO;
+import umc.cockple.demo.domain.notification.dto.FcmTokenRequestDTO;
+import umc.cockple.demo.domain.notification.fcm.FcmService;
+import umc.cockple.demo.domain.notification.service.NotificationCommandService;
+import umc.cockple.demo.domain.notification.dto.ExistNewNotificationResponseDTO;
+import umc.cockple.demo.domain.notification.service.NotificationQueryService;
+import umc.cockple.demo.global.response.BaseResponse;
+import umc.cockple.demo.global.response.code.status.CommonSuccessCode;
+import umc.cockple.demo.global.security.utils.SecurityUtil;
+
+import java.util.List;
+
+import static umc.cockple.demo.domain.notification.dto.MarkAsReadDTO.*;
+
+@RestController
+@RequestMapping("/api")
+@RequiredArgsConstructor
+@Validated
+@Tag(name = "Notification", description = "알림 API")
+public class NotificationController {
+
+ private final NotificationQueryService notificationQueryService;
+ private final NotificationCommandService notificationCommandService;
+ private final FcmService fcmService;
+
+ @GetMapping("/notifications")
+ @Operation(summary = "내 알림 전체 조회",
+ description = "사용자에게 온 알림 전체를 조회합니다. ")
+ public BaseResponse> getAllNotifications() {
+
+ Long memberId = SecurityUtil.getCurrentMemberId();
+
+ return BaseResponse.success(CommonSuccessCode.OK, notificationQueryService.getAllNotifications(memberId));
+ }
+
+
+
+ @GetMapping("/notifications/count")
+ @Operation(summary = "안 읽은 알림 존재여부 조회",
+ description = "사용자가 읽지 않은 알림이 있는지 확인합니다. 존재 시 알림 아이콘에 빨간 점이 표시됩니다 ")
+ public BaseResponse checkUnReadNotification() {
+
+ Long memberId = SecurityUtil.getCurrentMemberId();
+
+ return BaseResponse.success(CommonSuccessCode.OK,notificationQueryService.checkUnreadNotification(memberId));
+ }
+
+
+
+ @PatchMapping("/notifications/{notificationId}")
+ @Operation(summary = "내 특정 알림 조회 및 읽음 처리",
+ description = "특정 알림을 조회하고 읽음 처리를 진행합니다. ")
+ public BaseResponse markAsReadNotification(@PathVariable Long notificationId,
+ Request type) {
+ Long memberId = SecurityUtil.getCurrentMemberId();
+
+ return BaseResponse.success(CommonSuccessCode.OK, notificationCommandService.markAsReadNotification(memberId, notificationId, type.type()));
+ }
+
+
+ // ========== FCM 토큰 등록/갱신 API ==========
+ @PatchMapping("/notifications/fcm-token")
+ @Operation(summary = "FCM 토큰 등록",
+ description = "디바이스의 FCM 토큰을 등록하거나 갱신합니다. 알림 권한 거부 시 null을 전달해 토큰을 삭제합니다.")
+ public BaseResponse registerFcmToken(@RequestBody @Valid FcmTokenRequestDTO request) {
+ Long memberId = SecurityUtil.getCurrentMemberId();
+ fcmService.registerFcmToken(memberId, request.fcmToken());
+ return BaseResponse.success(CommonSuccessCode.OK, null);
+ }
+
+
+}
diff --git a/src/main/java/umc/cockple/demo/domain/notification/dto/FcmTokenRequestDTO.java b/src/main/java/umc/cockple/demo/domain/notification/dto/FcmTokenRequestDTO.java
new file mode 100644
index 000000000..a96777e63
--- /dev/null
+++ b/src/main/java/umc/cockple/demo/domain/notification/dto/FcmTokenRequestDTO.java
@@ -0,0 +1,9 @@
+package umc.cockple.demo.domain.notification.dto;
+
+import jakarta.validation.constraints.NotBlank;
+
+public record FcmTokenRequestDTO(
+ @NotBlank(message = "FCM 토큰은 필수입니다.")
+ String fcmToken
+) {
+}
diff --git a/src/main/java/umc/cockple/demo/domain/notification/exception/NotificationErrorCode.java b/src/main/java/umc/cockple/demo/domain/notification/exception/NotificationErrorCode.java
index 537fde9d8..0d1e78118 100644
--- a/src/main/java/umc/cockple/demo/domain/notification/exception/NotificationErrorCode.java
+++ b/src/main/java/umc/cockple/demo/domain/notification/exception/NotificationErrorCode.java
@@ -18,11 +18,11 @@ public enum NotificationErrorCode implements BaseErrorCode {
*/
NOTIFICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "NOTIFICATION201", "해당 알림이 존재하지 않습니다."),
-
-
NOTIFICATION_NOT_OWNED(HttpStatus.UNAUTHORIZED, "NOTIFICATION301", "해당 알림에 대한 권한이 없습니다."),
+
INVALID_NOTIFICATION_DATA(HttpStatus.INTERNAL_SERVER_ERROR, "NOTIFICATION501", "데이터 직렬화에 실패했습니다."),
+ FAIL_INIT_FIREBASE(HttpStatus.INTERNAL_SERVER_ERROR, "NOTIFICATION502", "Firebase 초기화에 실패했습니다.")
;
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
new file mode 100644
index 000000000..ab84bdef2
--- /dev/null
+++ b/src/main/java/umc/cockple/demo/domain/notification/fcm/FcmService.java
@@ -0,0 +1,54 @@
+package umc.cockple.demo.domain.notification.fcm;
+
+import com.google.firebase.messaging.FirebaseMessaging;
+import com.google.firebase.messaging.FirebaseMessagingException;
+import com.google.firebase.messaging.Message;
+import com.google.firebase.messaging.Notification;
+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;
+import umc.cockple.demo.domain.member.exception.MemberErrorCode;
+import umc.cockple.demo.domain.member.exception.MemberException;
+import umc.cockple.demo.domain.member.repository.MemberRepository;
+
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class FcmService {
+
+ private final MemberRepository memberRepository;
+ private final FirebaseMessaging firebaseMessaging;
+
+ @Transactional
+ public void registerFcmToken(Long memberId, String fcmToken) {
+ Member member = memberRepository.findById(memberId)
+ .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND));
+ member.updateFcmToken(fcmToken);
+ log.info("FCM 토큰 등록 완료 - memberId: {}", memberId);
+ }
+
+ public void sendNotification(Member member, String title, String content) {
+ String fcmToken = member.getFcmToken();
+ if (fcmToken == null || fcmToken.isBlank()) {
+ log.info("FCM 토큰 없음 - memberId: {}, 알림 전송 생략", member.getId());
+ return;
+ }
+
+ Message message = Message.builder()
+ .setToken(fcmToken)
+ .setNotification(Notification.builder()
+ .setTitle(title)
+ .setBody(content)
+ .build())
+ .build();
+
+ try {
+ firebaseMessaging.send(message);
+ log.info("FCM 전송 완료 - memberId: {}", member.getId());
+ } catch (FirebaseMessagingException e) {
+ log.error("FCM 전송 실패 - memberId: {}, error: {}", member.getId(), e.getMessage());
+ }
+ }
+}
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 c371e57c0..eb870bec6 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
@@ -10,8 +10,7 @@
public interface NotificationRepository extends JpaRepository {
- List findAllByMember(Member member);
+ List findAllByMemberOrderByCreatedAtDesc(Member member);
Optional findFirstByMemberAndTypeNotOrderByCreatedAtAsc(Member member, NotificationType type);
-
}
diff --git a/src/main/java/umc/cockple/demo/domain/notification/service/NotificationCommandService.java b/src/main/java/umc/cockple/demo/domain/notification/service/NotificationCommandService.java
index 78f59d9ee..f62a4d6f5 100644
--- a/src/main/java/umc/cockple/demo/domain/notification/service/NotificationCommandService.java
+++ b/src/main/java/umc/cockple/demo/domain/notification/service/NotificationCommandService.java
@@ -1,138 +1,141 @@
-package umc.cockple.demo.domain.notification.service;
-
-import com.fasterxml.jackson.core.JsonProcessingException;
-import com.fasterxml.jackson.databind.ObjectMapper;
-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;
-import umc.cockple.demo.domain.member.repository.MemberRepository;
-import umc.cockple.demo.domain.notification.domain.Notification;
-import umc.cockple.demo.domain.notification.dto.CreateNotificationRequestDTO;
-import umc.cockple.demo.domain.notification.enums.NotificationTarget;
-import umc.cockple.demo.domain.notification.exception.NotificationErrorCode;
-import umc.cockple.demo.domain.notification.exception.NotificationException;
-import umc.cockple.demo.domain.notification.repository.NotificationRepository;
-import umc.cockple.demo.domain.notification.enums.NotificationType;
-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;
-
-import java.time.LocalDate;
-import java.time.format.DateTimeFormatter;
-import java.time.format.TextStyle;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Locale;
-import java.util.Map;
-
-import static umc.cockple.demo.domain.notification.dto.MarkAsReadDTO.*;
-
-@Service
-@Transactional
-@RequiredArgsConstructor
-@Slf4j
-public class NotificationCommandService {
-
- private final NotificationRepository notificationRepository;
- private final MemberRepository memberRepository;
- private final PartyRepository partyRepository;
- private final NotificationMessageGenerator notificationMessageGenerator;
- private final ObjectMapper objectMapper;
-
- // 알림 타입 변경 (초대 수락, 거절에 사용)
- public Response markAsReadNotification(Long memberId, Long notificationId, NotificationType type) {
- Notification notification = findByNotificationId(notificationId);
-
- if (!notification.getMember().getId().equals(memberId)) {
- throw new NotificationException(NotificationErrorCode.NOTIFICATION_NOT_OWNED);
- }
-
- notification.changeType(type);
-
- notification.read();
-
- return new Response(notification.getType());
- }
-
- public void createNotification(CreateNotificationRequestDTO dto) {
- try {
-
- Member member = dto.member();
- List bookmarks = notificationRepository.findAllByMember(member);
- if (bookmarks.size() >= 50) {
- // INVITE타입이 아니면서 가장 오래된 거 삭제
- notificationRepository.findFirstByMemberAndTypeNotOrderByCreatedAtAsc(member, NotificationType.INVITE)
- .ifPresent(notificationRepository::delete);
- }
-
- Party party = partyRepository.findById(dto.partyId())
- .orElseThrow(() -> new PartyException(PartyErrorCode.PARTY_NOT_FOUND));
-
- Map context = new HashMap<>();
- if (dto.exerciseId() != null) context.put("exerciseId", dto.exerciseId());
- if (dto.exerciseDate() != null) context.put("exerciseDate", dto.exerciseDate());
- if (dto.invitationId() != null) context.put("invitationId", dto.invitationId());
-
- String content;
- String title = party.getPartyName();
- if (dto.target() == NotificationTarget.EXERCISE_DELETE) {
- String result = extractExerciseDateFormat(dto.exerciseDate());
- content = notificationMessageGenerator.generateExerciseDeletedMessage(result);
- } else if (dto.target() == NotificationTarget.EXERCISE_MODIFY) {
- String result = extractExerciseDateFormat(dto.exerciseDate());
- content = notificationMessageGenerator.generateExerciseChangedMessage(result);
- } else if (dto.target() == NotificationTarget.EXERCISE_ATTENDANCE) {
- content = notificationMessageGenerator.generateExerciseAttendChangedMessage();
- } else if (dto.target() == NotificationTarget.PARTY_DELETE) {
- content = notificationMessageGenerator.generatePartyDeletedMessage();
- } else if (dto.target() == NotificationTarget.PARTY_MODIFY) {
- content = notificationMessageGenerator.generatePartyInfoChangedMessage();
- } else if (dto.target() == NotificationTarget.PARTY_INVITE) {
- content = notificationMessageGenerator.generateInviteMessage(party.getPartyName());
- title = "새로운 모임";
- } else if (dto.target() == NotificationTarget.PARTY_INVITE_APPROVED) {
- content = notificationMessageGenerator.generateInviteApprovedMessage(dto.subjectName());
- } else if (dto.target() == NotificationTarget.PARTY_SUBOWNER_ASSIGNED) {
- content = notificationMessageGenerator.generateSubOwnerAssignedMessage(dto.subjectName());
- } else if (dto.target() == NotificationTarget.PARTY_SUBOWNER_RELEASED) {
- content = notificationMessageGenerator.generateSubOwnerReleasedMessage(dto.subjectName());
- } else {
- content = notificationMessageGenerator.generateJoinRequestApprovedMessage();
- }
-
- String data = objectMapper.writeValueAsString(context);
-
- Notification notification = Notification.builder()
- .member(dto.member())
- .partyId(dto.partyId())
- .title(title)
- .content(content)
- .type(dto.target().getDefaultType())
- .isRead(false)
- .imageKey(party.getPartyImg() != null ? party.getPartyImg().getImgKey() : null)
- .data(data)
- .build();
-
- notificationRepository.save(notification);
-
- } catch (JsonProcessingException e) {
- throw new NotificationException(NotificationErrorCode.INVALID_NOTIFICATION_DATA);
- }
- }
-
- private Notification findByNotificationId(Long notification) {
- return notificationRepository.findById(notification)
- .orElseThrow(() -> new NotificationException(NotificationErrorCode.NOTIFICATION_NOT_FOUND));
- }
-
- private String extractExerciseDateFormat(LocalDate date) {
- // 날짜 요일 포매팅 (MM.dd(요일))
- String format = date.format(DateTimeFormatter.ofPattern("MM.dd"));
- String day = date.getDayOfWeek().getDisplayName(TextStyle.SHORT, Locale.KOREAN);
- return format + "(" + day + ")";
- }
-
-}
+package umc.cockple.demo.domain.notification.service;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+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;
+import umc.cockple.demo.domain.member.repository.MemberRepository;
+import umc.cockple.demo.domain.notification.domain.Notification;
+import umc.cockple.demo.domain.notification.dto.CreateNotificationRequestDTO;
+import umc.cockple.demo.domain.notification.enums.NotificationTarget;
+import umc.cockple.demo.domain.notification.exception.NotificationErrorCode;
+import umc.cockple.demo.domain.notification.exception.NotificationException;
+import umc.cockple.demo.domain.notification.fcm.FcmService;
+import umc.cockple.demo.domain.notification.repository.NotificationRepository;
+import umc.cockple.demo.domain.notification.enums.NotificationType;
+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;
+
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.time.format.TextStyle;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import static umc.cockple.demo.domain.notification.dto.MarkAsReadDTO.*;
+
+@Service
+@Transactional
+@RequiredArgsConstructor
+@Slf4j
+public class NotificationCommandService {
+
+ private final NotificationRepository notificationRepository;
+ private final MemberRepository memberRepository;
+ private final PartyRepository partyRepository;
+ private final NotificationMessageGenerator notificationMessageGenerator;
+ private final ObjectMapper objectMapper;
+ private final FcmService fcmService;
+
+ // 알림 타입 변경 (초대 수락, 거절에 사용)
+ public Response markAsReadNotification(Long memberId, Long notificationId, NotificationType type) {
+ Notification notification = findByNotificationId(notificationId);
+
+ if (!notification.getMember().getId().equals(memberId)) {
+ throw new NotificationException(NotificationErrorCode.NOTIFICATION_NOT_OWNED);
+ }
+
+ notification.changeType(type);
+
+ notification.read();
+
+ return new Response(notification.getType());
+ }
+
+ public void createNotification(CreateNotificationRequestDTO dto) {
+ try {
+
+ Member member = dto.member();
+ List bookmarks = notificationRepository.findAllByMemberOrderByCreatedAtDesc(member);
+ if (bookmarks.size() >= 50) {
+ // INVITE타입이 아니면서 가장 오래된 거 삭제
+ notificationRepository.findFirstByMemberAndTypeNotOrderByCreatedAtAsc(member, NotificationType.INVITE)
+ .ifPresent(notificationRepository::delete);
+ }
+
+ Party party = partyRepository.findById(dto.partyId())
+ .orElseThrow(() -> new PartyException(PartyErrorCode.PARTY_NOT_FOUND));
+
+ Map context = new HashMap<>();
+ if (dto.exerciseId() != null) context.put("exerciseId", dto.exerciseId());
+ if (dto.exerciseDate() != null) context.put("exerciseDate", dto.exerciseDate());
+ if (dto.invitationId() != null) context.put("invitationId", dto.invitationId());
+
+ String content;
+ String title = party.getPartyName();
+ if (dto.target() == NotificationTarget.EXERCISE_DELETE) {
+ String result = extractExerciseDateFormat(dto.exerciseDate());
+ content = notificationMessageGenerator.generateExerciseDeletedMessage(result);
+ } else if (dto.target() == NotificationTarget.EXERCISE_MODIFY) {
+ String result = extractExerciseDateFormat(dto.exerciseDate());
+ content = notificationMessageGenerator.generateExerciseChangedMessage(result);
+ } else if (dto.target() == NotificationTarget.EXERCISE_ATTENDANCE) {
+ content = notificationMessageGenerator.generateExerciseAttendChangedMessage();
+ } else if (dto.target() == NotificationTarget.PARTY_DELETE) {
+ content = notificationMessageGenerator.generatePartyDeletedMessage();
+ } else if (dto.target() == NotificationTarget.PARTY_MODIFY) {
+ content = notificationMessageGenerator.generatePartyInfoChangedMessage();
+ } else if (dto.target() == NotificationTarget.PARTY_INVITE) {
+ content = notificationMessageGenerator.generateInviteMessage(party.getPartyName());
+ title = "새로운 모임";
+ } else if (dto.target() == NotificationTarget.PARTY_INVITE_APPROVED) {
+ content = notificationMessageGenerator.generateInviteApprovedMessage(dto.subjectName());
+ } else if (dto.target() == NotificationTarget.PARTY_SUBOWNER_ASSIGNED) {
+ content = notificationMessageGenerator.generateSubOwnerAssignedMessage(dto.subjectName());
+ } else if (dto.target() == NotificationTarget.PARTY_SUBOWNER_RELEASED) {
+ content = notificationMessageGenerator.generateSubOwnerReleasedMessage(dto.subjectName());
+ } else {
+ content = notificationMessageGenerator.generateJoinRequestApprovedMessage();
+ }
+
+ String data = objectMapper.writeValueAsString(context);
+
+ Notification notification = Notification.builder()
+ .member(dto.member())
+ .partyId(dto.partyId())
+ .title(title)
+ .content(content)
+ .type(dto.target().getDefaultType())
+ .isRead(false)
+ .imageKey(party.getPartyImg() != null ? party.getPartyImg().getImgKey() : null)
+ .data(data)
+ .build();
+
+ notificationRepository.save(notification);
+ fcmService.sendNotification(member, title, content);
+
+ } catch (JsonProcessingException e) {
+ throw new NotificationException(NotificationErrorCode.INVALID_NOTIFICATION_DATA);
+ }
+ }
+
+ private Notification findByNotificationId(Long notification) {
+ return notificationRepository.findById(notification)
+ .orElseThrow(() -> new NotificationException(NotificationErrorCode.NOTIFICATION_NOT_FOUND));
+ }
+
+ private String extractExerciseDateFormat(LocalDate date) {
+ // 날짜 요일 포매팅 (MM.dd(요일))
+ String format = date.format(DateTimeFormatter.ofPattern("MM.dd"));
+ String day = date.getDayOfWeek().getDisplayName(TextStyle.SHORT, Locale.KOREAN);
+ return format + "(" + day + ")";
+ }
+
+}
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 ec03d694b..baa08008d 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
@@ -4,12 +4,11 @@
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
-import umc.cockple.demo.domain.image.service.ImageService;
+import umc.cockple.demo.domain.file.service.FileService;
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.domain.notification.controller.NotificationController;
import umc.cockple.demo.domain.notification.converter.NotificationConverter;
import umc.cockple.demo.domain.notification.domain.Notification;
import umc.cockple.demo.domain.notification.dto.AllNotificationsResponseDTO;
@@ -17,7 +16,6 @@
import umc.cockple.demo.domain.notification.repository.NotificationRepository;
import java.util.List;
-import java.util.stream.Collectors;
@Service
@Transactional(readOnly = true)
@@ -27,7 +25,7 @@ public class NotificationQueryService {
private final NotificationRepository notificationRepository;
private final MemberRepository memberRepository;
- private final ImageService imageService;
+ private final FileService fileService;
public List getAllNotifications(Long memberId) {
@@ -35,7 +33,7 @@ public List getAllNotifications(Long memberId) {
Member member = findByMemberId(memberId);
// 회원의 모든 알림 조회
- List notifications = notificationRepository.findAllByMember(member);
+ List notifications = notificationRepository.findAllByMemberOrderByCreatedAtDesc(member);
if (notifications.isEmpty()) {
return List.of();
@@ -43,7 +41,7 @@ public List getAllNotifications(Long memberId) {
// dto 매핑 및 반환
return notifications.stream()
.map(notification -> {
- String url = imageService.getUrlFromKey(notification.getImageKey());
+ String url = fileService.getUrlFromKey(notification.getImageKey());
return NotificationConverter.toAllNotificationResponseDTO(notification, url);
})
.toList();
diff --git a/src/main/java/umc/cockple/demo/domain/party/converter/PartyConverter.java b/src/main/java/umc/cockple/demo/domain/party/converter/PartyConverter.java
index 70448c423..e331e6c68 100644
--- a/src/main/java/umc/cockple/demo/domain/party/converter/PartyConverter.java
+++ b/src/main/java/umc/cockple/demo/domain/party/converter/PartyConverter.java
@@ -2,7 +2,7 @@
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
-import umc.cockple.demo.domain.image.service.ImageService;
+import umc.cockple.demo.domain.file.service.FileService;
import umc.cockple.demo.domain.member.domain.Member;
import umc.cockple.demo.domain.member.domain.MemberParty;
import umc.cockple.demo.domain.member.domain.ProfileImg;
@@ -25,7 +25,7 @@
@RequiredArgsConstructor
public class PartyConverter {
- private final ImageService imageService;
+ private final FileService fileService;
public PartySimpleDTO.Response toPartySimpleDTO(MemberParty memberParty, String imgUrl) {
Party party = memberParty.getParty();
@@ -211,16 +211,16 @@ public PartyMemberSuggestionDTO.Response toPartyMemberSuggestionDTO(Member membe
private int getRolePriority(String role) {
return switch (role) {
- case "party_MANAGER" -> 0; // 모임장 역할
- case "party_SUBMANAGER" -> 1; // 부모임장
- case "party_MEMBER" -> 2; // 일반 멤버
+ case "PARTY_MANAGER" -> 0; // 모임장 역할
+ case "PARTY_SUBMANAGER" -> 1; // 부모임장
+ case "PARTY_MEMBER" -> 2; // 일반 멤버
default -> 99;
};
}
private String getProfileUrl(ProfileImg profileImg) {
if (profileImg != null && profileImg.getImgKey() != null && !profileImg.getImgKey().isBlank()) {
- return imageService.getUrlFromKey(profileImg.getImgKey());
+ return fileService.getUrlFromKey(profileImg.getImgKey());
}
return null;
}
diff --git a/src/main/java/umc/cockple/demo/domain/party/exception/PartyErrorCode.java b/src/main/java/umc/cockple/demo/domain/party/exception/PartyErrorCode.java
index e844ab093..abc159c4d 100644
--- a/src/main/java/umc/cockple/demo/domain/party/exception/PartyErrorCode.java
+++ b/src/main/java/umc/cockple/demo/domain/party/exception/PartyErrorCode.java
@@ -24,10 +24,10 @@ public enum PartyErrorCode implements BaseErrorCode {
INVALID_ORDER_TYPE(HttpStatus.BAD_REQUEST, "PARTY106", "유효하지 않은 정렬 기준입니다. (최신순, 오래된 순, 운동 많은 순 중 하나여야 합니다.)"),
INVALID_KEYWORD(HttpStatus.BAD_REQUEST, "PARTY107", "유효하지 않은 키워드입니다."),
MALE_LEVEL_NOT_NEEDED(HttpStatus.BAD_REQUEST, "PARTY108", "여복 모임은 남자 급수를 설정할 수 없습니다."),
- INVALID_ROLE_VALUE(HttpStatus.BAD_REQUEST, "PARTY411", "유효하지 않은 역할 값입니다. (party_SUBMANAGER 또는 party_MEMBER를 입력해주세요.)"),
+ INVALID_ROLE_VALUE(HttpStatus.BAD_REQUEST, "PARTY411", "유효하지 않은 역할 값입니다. (PARTY_SUBMANAGER 또는 PARTY_MEMBER를 입력해주세요.)"),
PARTY_NOT_FOUND(HttpStatus.NOT_FOUND, "PARTY201", "존재하지 않는 모임입니다."),
- JoinRequest_NOT_FOUND(HttpStatus.NOT_FOUND, "PARTY202", "존재하지 않는 가입신청입니다."),
+ JOIN_REQUEST_NOT_FOUND(HttpStatus.NOT_FOUND, "PARTY202", "존재하지 않는 가입신청입니다."),
JOIN_REQUEST_PARTY_NOT_FOUND(HttpStatus.NOT_FOUND, "PARTY203", "해당 모임에서 존재하지 않는 가입신청입니다."),
NOT_MEMBER(HttpStatus.BAD_REQUEST, "PARTY204", "해당 모임의 멤버가 아닙니다."),
INVITATION_NOT_FOUND(HttpStatus.NOT_FOUND, "PARTY205", "존재하지 않는 모임 초대입니다."),
diff --git a/src/main/java/umc/cockple/demo/domain/party/service/PartyCommandServiceImpl.java b/src/main/java/umc/cockple/demo/domain/party/service/PartyCommandServiceImpl.java
index f3624188a..9616b587a 100644
--- a/src/main/java/umc/cockple/demo/domain/party/service/PartyCommandServiceImpl.java
+++ b/src/main/java/umc/cockple/demo/domain/party/service/PartyCommandServiceImpl.java
@@ -190,7 +190,7 @@ public void updateMemberRole(Long partyId, Long targetMemberId, Long currentMemb
// 모임장 권한 검증
validateOwnerPermission(party, currentMemberId);
// 대상이 모임장인 경우 변경 불가
- if (targetMemberParty.getRole() == Role.party_MANAGER) {
+ if (targetMemberParty.getRole() == Role.PARTY_MANAGER) {
throw new PartyException(PartyErrorCode.CANNOT_ASSIGN_TO_OWNER);
}
// 이미 같은 역할인 경우
@@ -199,10 +199,10 @@ public void updateMemberRole(Long partyId, Long targetMemberId, Long currentMemb
}
// SUBOWNER 지정 시, 기존 부모임장 자동 해제
- if (newRole == Role.party_SUBMANAGER) {
- memberPartyRepository.findByPartyIdAndRole(partyId, Role.party_SUBMANAGER)
+ if (newRole == Role.PARTY_SUBMANAGER) {
+ memberPartyRepository.findByPartyIdAndRole(partyId, Role.PARTY_SUBMANAGER)
.ifPresent(mp -> {
- mp.changeRole(Role.party_MEMBER);
+ mp.changeRole(Role.PARTY_MEMBER);
createRoleNotification(partyId, NotificationTarget.PARTY_SUBOWNER_RELEASED,
mp.getMember().getNickname());
});
@@ -212,7 +212,7 @@ public void updateMemberRole(Long partyId, Long targetMemberId, Long currentMemb
targetMemberParty.changeRole(newRole);
// 알림 발송 (전체 멤버 대상)
- NotificationTarget notifTarget = (newRole == Role.party_SUBMANAGER)
+ NotificationTarget notifTarget = (newRole == Role.PARTY_SUBMANAGER)
? NotificationTarget.PARTY_SUBOWNER_ASSIGNED
: NotificationTarget.PARTY_SUBOWNER_RELEASED;
createRoleNotification(partyId, notifTarget, targetMember.getNickname());
@@ -335,7 +335,7 @@ public void addKeyword(Long partyId, Long memberId, PartyKeywordDTO.Request requ
//가입신청 조회
private PartyJoinRequest findJoinRequestOrThrow(Long requestId) {
return partyJoinRequestRepository.findById(requestId)
- .orElseThrow(() -> new PartyException(PartyErrorCode.JoinRequest_NOT_FOUND));
+ .orElseThrow(() -> new PartyException(PartyErrorCode.JOIN_REQUEST_NOT_FOUND));
}
private PartyInvitation findInvitationOrThrow(Long invitationId) {
@@ -387,7 +387,7 @@ private void validateIsNotOwner(Party party, Long memberId) {
// 부모임장은 권한이 없음을 검증
private void validateIsNotSubOwner(Party party, Long memberId) {
- memberPartyRepository.findByPartyIdAndRole(party.getId(), Role.party_SUBMANAGER)
+ memberPartyRepository.findByPartyIdAndRole(party.getId(), Role.PARTY_SUBMANAGER)
.ifPresent(mp -> {
if (mp.getMember().getId().equals(memberId)) {
throw new PartyException(PartyErrorCode.INVALID_ACTION_FOR_SUBOWNER);
@@ -427,7 +427,7 @@ private void validateRemovalPermission(Party party, Member remover, MemberParty
if (remover.getId().equals(memberPartyToRemove.getMember().getId())) {
//부모임장인 경우에만 가능
MemberParty removerMemberParty = findMemberPartyOrThrow(party, remover);
- if (removerMemberParty.getRole() == Role.party_SUBMANAGER) {
+ if (removerMemberParty.getRole() == Role.PARTY_SUBMANAGER) {
return;
} else {
throw new PartyException(PartyErrorCode.CANNOT_REMOVE_SELF);
@@ -439,11 +439,11 @@ private void validateRemovalPermission(Party party, Member remover, MemberParty
Role removerRole = removerMemberParty.getRole();
Role targetRole = memberPartyToRemove.getRole();
//모임장은 모두 삭제 가능
- if (removerRole == Role.party_MANAGER) {
+ if (removerRole == Role.PARTY_MANAGER) {
return;
}
//부모임장은 일반 멤버만 삭제 가능 (모임장을 삭제하려할 경우 권한 없음)
- if (removerRole == Role.party_SUBMANAGER && targetRole == Role.party_MEMBER) {
+ if (removerRole == Role.PARTY_SUBMANAGER && targetRole == Role.PARTY_MEMBER) {
return;
}
//일반 멤버는 권한 없음
diff --git a/src/main/java/umc/cockple/demo/domain/party/service/PartyQueryServiceImpl.java b/src/main/java/umc/cockple/demo/domain/party/service/PartyQueryServiceImpl.java
index 82894ac5f..d36e247f0 100644
--- a/src/main/java/umc/cockple/demo/domain/party/service/PartyQueryServiceImpl.java
+++ b/src/main/java/umc/cockple/demo/domain/party/service/PartyQueryServiceImpl.java
@@ -9,7 +9,7 @@
import umc.cockple.demo.domain.bookmark.repository.PartyBookmarkRepository;
import umc.cockple.demo.domain.exercise.domain.Exercise;
import umc.cockple.demo.domain.exercise.repository.ExerciseRepository;
-import umc.cockple.demo.domain.image.service.ImageService;
+import umc.cockple.demo.domain.file.service.FileService;
import umc.cockple.demo.domain.member.domain.*;
import umc.cockple.demo.domain.member.exception.MemberErrorCode;
import umc.cockple.demo.domain.member.exception.MemberException;
@@ -53,7 +53,7 @@ public class PartyQueryServiceImpl implements PartyQueryService{
private final ExerciseRepository exerciseRepository;
private final MemberExerciseRepository memberExerciseRepository;
private final PartyBookmarkRepository partyBookmarkRepository;
- private final ImageService imageService;
+ private final FileService fileService;
@Override
public Slice getSimpleMyParties(Long memberId, Pageable pageable) {
@@ -307,7 +307,7 @@ private Slice getCockpleRecommendedParties(Long memberId, String search,
//이름 검색 필터 적용
List searchedParties = filterByName(filteredParties, search);
//키워드 일치 개수로 정렬
- List sortedParties = sortPartiesByKeywordMatch(filteredParties, partiesInfo.keywords());
+ List sortedParties = sortPartiesByKeywordMatch(searchedParties, partiesInfo.keywords());
//수동으로 페이징
Slice partySlice = paginate(sortedParties, pageable);
@@ -371,14 +371,14 @@ private boolean hasPendingJoinRequest(Party party, Member member, Optional());
+ given(partyBookmarkRepository.save(any(PartyBookmark.class))).willReturn(savedBookmark);
+
+ // when
+ Long result = bookmarkCommandService.partyBookmark(member.getId(), party.getId());
+
+ // then
+ assertThat(result).isEqualTo(50L);
+ then(partyBookmarkRepository).should().save(any(PartyBookmark.class));
+ }
+
+ @Test
+ @DisplayName("찜 목록이 15개 이상이면 가장 오래된 북마크를 삭제하고 새로 저장한다")
+ void createPartyBookmark_deletesOldestWhenLimitExceeded() {
+ // given
+ List existingBookmarks = new ArrayList<>();
+ for (int i = 0; i < 15; i++) {
+ existingBookmarks.add(PartyBookmark.builder()
+ .member(member)
+ .party(party)
+ .orderType(PartyOrderType.LATEST)
+ .build());
+ }
+
+ PartyBookmark oldestBookmark = PartyBookmark.builder()
+ .member(member)
+ .party(party)
+ .orderType(PartyOrderType.LATEST)
+ .build();
+
+ PartyBookmark savedBookmark = PartyBookmark.builder()
+ .member(member)
+ .party(party)
+ .orderType(PartyOrderType.LATEST)
+ .build();
+ ReflectionTestUtils.setField(savedBookmark, "id", 99L);
+
+ given(memberRepository.findById(member.getId())).willReturn(Optional.of(member));
+ given(partyRepository.findById(party.getId())).willReturn(Optional.of(party));
+ given(partyBookmarkRepository.existsByMemberAndParty(member, party)).willReturn(false);
+ given(partyBookmarkRepository.findAllByMember(member)).willReturn(existingBookmarks);
+ given(partyBookmarkRepository.findFirstByMemberOrderByCreatedAtAsc(member))
+ .willReturn(Optional.of(oldestBookmark));
+ given(partyBookmarkRepository.save(any(PartyBookmark.class))).willReturn(savedBookmark);
+
+ // when
+ Long result = bookmarkCommandService.partyBookmark(member.getId(), party.getId());
+
+ // then
+ assertThat(result).isEqualTo(99L);
+ then(partyBookmarkRepository).should().delete(oldestBookmark);
+ then(partyBookmarkRepository).should().save(any(PartyBookmark.class));
+ }
+ }
+
+ @Nested
+ @DisplayName("실패 케이스")
+ class Failure {
+
+ @Test
+ @DisplayName("존재하지 않는 회원이면 MemberException(MEMBER_NOT_FOUND)을 던진다")
+ void memberNotFound_throwsMemberException() {
+ given(memberRepository.findById(999L)).willReturn(Optional.empty());
+
+ assertThatThrownBy(() ->
+ bookmarkCommandService.partyBookmark(999L, party.getId()))
+ .isInstanceOf(MemberException.class)
+ .satisfies(e -> assertThat(((MemberException) e).getCode())
+ .isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND));
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 모임이면 PartyException(PARTY_NOT_FOUND)을 던진다")
+ void partyNotFound_throwsPartyException() {
+ given(memberRepository.findById(member.getId())).willReturn(Optional.of(member));
+ given(partyRepository.findById(999L)).willReturn(Optional.empty());
+
+ assertThatThrownBy(() ->
+ bookmarkCommandService.partyBookmark(member.getId(), 999L))
+ .isInstanceOf(PartyException.class)
+ .satisfies(e -> assertThat(((PartyException) e).getCode())
+ .isEqualTo(PartyErrorCode.PARTY_NOT_FOUND));
+ }
+
+ @Test
+ @DisplayName("비활성화된 모임이면 PartyException(PARTY_IS_DELETED)을 던진다")
+ void partyIsInactive_throwsPartyException() {
+ Party inactiveParty = PartyFixture.createParty("삭제된 모임", member.getId(),
+ PartyFixture.createPartyAddr("서울특별시", "강남구"));
+ ReflectionTestUtils.setField(inactiveParty, "id", 20L);
+ ReflectionTestUtils.setField(inactiveParty, "status", PartyStatus.INACTIVE);
+
+ given(memberRepository.findById(member.getId())).willReturn(Optional.of(member));
+ given(partyRepository.findById(20L)).willReturn(Optional.of(inactiveParty));
+
+ assertThatThrownBy(() ->
+ bookmarkCommandService.partyBookmark(member.getId(), 20L))
+ .isInstanceOf(PartyException.class)
+ .satisfies(e -> assertThat(((PartyException) e).getCode())
+ .isEqualTo(PartyErrorCode.PARTY_IS_DELETED));
+ }
+
+ @Test
+ @DisplayName("이미 찜한 모임이면 BookmarkException(ALREADY_BOOKMARK)을 던진다")
+ void alreadyBookmarked_throwsBookmarkException() {
+ given(memberRepository.findById(member.getId())).willReturn(Optional.of(member));
+ given(partyRepository.findById(party.getId())).willReturn(Optional.of(party));
+ given(partyBookmarkRepository.existsByMemberAndParty(member, party)).willReturn(true);
+
+ assertThatThrownBy(() ->
+ bookmarkCommandService.partyBookmark(member.getId(), party.getId()))
+ .isInstanceOf(BookmarkException.class)
+ .satisfies(e -> assertThat(((BookmarkException) e).getCode())
+ .isEqualTo(BookmarkErrorCode.ALREADY_BOOKMARK));
+
+ then(partyBookmarkRepository).should(never()).save(any());
+ }
+ }
+ }
+
+ @Nested
+ @DisplayName("releasePartyBookmark - 모임 찜 해제")
+ class ReleasePartyBookmarks {
+
+ @Nested
+ @DisplayName("성공 케이스")
+ class Success {
+
+ @Test
+ @DisplayName("찜한 모임을 해제하면 북마크를 삭제한다")
+ void releasePartyBookmark_deletesBookmark() {
+ // given
+ PartyBookmark bookmark = PartyBookmark.builder()
+ .member(member)
+ .party(party)
+ .orderType(PartyOrderType.LATEST)
+ .build();
+
+ given(memberRepository.findById(member.getId())).willReturn(Optional.of(member));
+ given(partyRepository.findById(party.getId())).willReturn(Optional.of(party));
+ given(partyBookmarkRepository.findByMemberAndParty(member, party))
+ .willReturn(Optional.of(bookmark));
+
+ // when
+ bookmarkCommandService.releasePartyBookmark(member.getId(), party.getId());
+
+ // then
+ then(partyBookmarkRepository).should().delete(bookmark);
+ }
+ }
+
+ @Nested
+ @DisplayName("실패 케이스")
+ class Failure {
+
+ @Test
+ @DisplayName("존재하지 않는 회원이면 MemberException(MEMBER_NOT_FOUND)을 던진다")
+ void memberNotFound_throwsMemberException() {
+ given(memberRepository.findById(999L)).willReturn(Optional.empty());
+
+ assertThatThrownBy(() ->
+ bookmarkCommandService.releasePartyBookmark(999L, party.getId()))
+ .isInstanceOf(MemberException.class)
+ .satisfies(e -> assertThat(((MemberException) e).getCode())
+ .isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND));
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 모임이면 PartyException(PARTY_NOT_FOUND)을 던진다")
+ void partyNotFound_throwsPartyException() {
+ given(memberRepository.findById(member.getId())).willReturn(Optional.of(member));
+ given(partyRepository.findById(999L)).willReturn(Optional.empty());
+
+ assertThatThrownBy(() ->
+ bookmarkCommandService.releasePartyBookmark(member.getId(), 999L))
+ .isInstanceOf(PartyException.class)
+ .satisfies(e -> assertThat(((PartyException) e).getCode())
+ .isEqualTo(PartyErrorCode.PARTY_NOT_FOUND));
+ }
+
+ @Test
+ @DisplayName("찜하지 않은 모임이면 BookmarkException(ALREADY_RELEASE_BOOKMARK)을 던진다")
+ void bookmarkNotFound_throwsBookmarkException() {
+ given(memberRepository.findById(member.getId())).willReturn(Optional.of(member));
+ given(partyRepository.findById(party.getId())).willReturn(Optional.of(party));
+ given(partyBookmarkRepository.findByMemberAndParty(member, party))
+ .willReturn(Optional.empty());
+
+ assertThatThrownBy(() ->
+ bookmarkCommandService.releasePartyBookmark(member.getId(), party.getId()))
+ .isInstanceOf(BookmarkException.class)
+ .satisfies(e -> assertThat(((BookmarkException) e).getCode())
+ .isEqualTo(BookmarkErrorCode.ALREADY_RELEASE_BOOKMARK));
+
+ then(partyBookmarkRepository).should(never()).delete(any());
+ }
+ }
+ }
+
+ @Nested
+ @DisplayName("exerciseBookmark - 운동 찜하기")
+ class ExerciseBookmarkCreate {
+
+ @Nested
+ @DisplayName("성공 케이스")
+ class Success {
+
+ @Test
+ @DisplayName("운동을 찜하면 저장된 북마크 id를 반환한다")
+ void createExerciseBookmark_returnsBookmarkId() {
+ // given
+ ExerciseBookmark savedBookmark = ExerciseBookmark.builder()
+ .member(member)
+ .exercise(exercise)
+ .build();
+ ReflectionTestUtils.setField(savedBookmark, "id", 200L);
+
+ given(memberRepository.findById(member.getId())).willReturn(Optional.of(member));
+ given(exerciseRepository.findById(exercise.getId())).willReturn(Optional.of(exercise));
+ given(exerciseBookmarkRepository.existsByMemberAndExercise(member, exercise)).willReturn(false);
+ given(exerciseBookmarkRepository.findAllByMember(member)).willReturn(new ArrayList<>());
+ given(exerciseBookmarkRepository.save(any(ExerciseBookmark.class))).willReturn(savedBookmark);
+
+ // when
+ Long result = bookmarkCommandService.exerciseBookmark(member.getId(), exercise.getId());
+
+ // then
+ assertThat(result).isEqualTo(200L);
+ then(exerciseBookmarkRepository).should().save(any(ExerciseBookmark.class));
+ }
+
+ @Test
+ @DisplayName("찜 목록이 50개 이상이면 가장 오래된 북마크를 삭제하고 새로 저장한다")
+ void createExerciseBookmark_deletesOldestWhenLimitExceeded() {
+ // given
+ List existingBookmarks = new ArrayList<>();
+ for (int i = 0; i < 50; i++) {
+ existingBookmarks.add(ExerciseBookmark.builder()
+ .member(member)
+ .exercise(exercise)
+ .build());
+ }
+
+ ExerciseBookmark oldestBookmark = ExerciseBookmark.builder()
+ .member(member)
+ .exercise(exercise)
+ .build();
+
+ ExerciseBookmark savedBookmark = ExerciseBookmark.builder()
+ .member(member)
+ .exercise(exercise)
+ .build();
+ ReflectionTestUtils.setField(savedBookmark, "id", 300L);
+
+ given(memberRepository.findById(member.getId())).willReturn(Optional.of(member));
+ given(exerciseRepository.findById(exercise.getId())).willReturn(Optional.of(exercise));
+ given(exerciseBookmarkRepository.existsByMemberAndExercise(member, exercise)).willReturn(false);
+ given(exerciseBookmarkRepository.findAllByMember(member)).willReturn(existingBookmarks);
+ given(exerciseBookmarkRepository.findFirstByMemberOrderByCreatedAtAsc(member))
+ .willReturn(Optional.of(oldestBookmark));
+ given(exerciseBookmarkRepository.save(any(ExerciseBookmark.class))).willReturn(savedBookmark);
+
+ // when
+ Long result = bookmarkCommandService.exerciseBookmark(member.getId(), exercise.getId());
+
+ // then
+ assertThat(result).isEqualTo(300L);
+ then(exerciseBookmarkRepository).should().delete(oldestBookmark);
+ then(exerciseBookmarkRepository).should().save(any(ExerciseBookmark.class));
+ }
+ }
+
+ @Nested
+ @DisplayName("실패 케이스")
+ class Failure {
+
+ @Test
+ @DisplayName("존재하지 않는 회원이면 MemberException(MEMBER_NOT_FOUND)을 던진다")
+ void memberNotFound_throwsMemberException() {
+ given(memberRepository.findById(999L)).willReturn(Optional.empty());
+
+ assertThatThrownBy(() ->
+ bookmarkCommandService.exerciseBookmark(999L, exercise.getId()))
+ .isInstanceOf(MemberException.class)
+ .satisfies(e -> assertThat(((MemberException) e).getCode())
+ .isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND));
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 운동이면 ExerciseException(EXERCISE_NOT_FOUND)을 던진다")
+ void exerciseNotFound_throwsExerciseException() {
+ given(memberRepository.findById(member.getId())).willReturn(Optional.of(member));
+ given(exerciseRepository.findById(999L)).willReturn(Optional.empty());
+
+ assertThatThrownBy(() ->
+ bookmarkCommandService.exerciseBookmark(member.getId(), 999L))
+ .isInstanceOf(ExerciseException.class)
+ .satisfies(e -> assertThat(((ExerciseException) e).getCode())
+ .isEqualTo(ExerciseErrorCode.EXERCISE_NOT_FOUND));
+ }
+
+ @Test
+ @DisplayName("이미 찜한 운동이면 BookmarkException(ALREADY_BOOKMARK)을 던진다")
+ void alreadyBookmarked_throwsBookmarkException() {
+ given(memberRepository.findById(member.getId())).willReturn(Optional.of(member));
+ given(exerciseRepository.findById(exercise.getId())).willReturn(Optional.of(exercise));
+ given(exerciseBookmarkRepository.existsByMemberAndExercise(member, exercise)).willReturn(true);
+
+ assertThatThrownBy(() ->
+ bookmarkCommandService.exerciseBookmark(member.getId(), exercise.getId()))
+ .isInstanceOf(BookmarkException.class)
+ .satisfies(e -> assertThat(((BookmarkException) e).getCode())
+ .isEqualTo(BookmarkErrorCode.ALREADY_BOOKMARK));
+
+ then(exerciseBookmarkRepository).should(never()).save(any());
+ }
+ }
+ }
+
+ @Nested
+ @DisplayName("releaseExerciseBookmark - 운동 찜 해제")
+ class ReleaseExerciseBookmark {
+
+ @Nested
+ @DisplayName("성공 케이스")
+ class Success {
+
+ @Test
+ @DisplayName("찜한 운동을 해제하면 북마크를 삭제한다")
+ void releaseExerciseBookmark_deletesBookmark() {
+ // given
+ ExerciseBookmark bookmark = ExerciseBookmark.builder()
+ .member(member)
+ .exercise(exercise)
+ .build();
+
+ given(memberRepository.findById(member.getId())).willReturn(Optional.of(member));
+ given(exerciseRepository.findById(exercise.getId())).willReturn(Optional.of(exercise));
+ given(exerciseBookmarkRepository.findByMemberAndExercise(member, exercise))
+ .willReturn(Optional.of(bookmark));
+
+ // when
+ bookmarkCommandService.releaseExerciseBookmark(member.getId(), exercise.getId());
+
+ // then
+ then(exerciseBookmarkRepository).should().delete(bookmark);
+ }
+ }
+
+ @Nested
+ @DisplayName("실패 케이스")
+ class Failure {
+
+ @Test
+ @DisplayName("존재하지 않는 회원이면 MemberException(MEMBER_NOT_FOUND)을 던진다")
+ void memberNotFound_throwsMemberException() {
+ given(memberRepository.findById(999L)).willReturn(Optional.empty());
+
+ assertThatThrownBy(() ->
+ bookmarkCommandService.releaseExerciseBookmark(999L, exercise.getId()))
+ .isInstanceOf(MemberException.class)
+ .satisfies(e -> assertThat(((MemberException) e).getCode())
+ .isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND));
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 운동이면 ExerciseException(EXERCISE_NOT_FOUND)을 던진다")
+ void exerciseNotFound_throwsExerciseException() {
+ given(memberRepository.findById(member.getId())).willReturn(Optional.of(member));
+ given(exerciseRepository.findById(999L)).willReturn(Optional.empty());
+
+ assertThatThrownBy(() ->
+ bookmarkCommandService.releaseExerciseBookmark(member.getId(), 999L))
+ .isInstanceOf(ExerciseException.class)
+ .satisfies(e -> assertThat(((ExerciseException) e).getCode())
+ .isEqualTo(ExerciseErrorCode.EXERCISE_NOT_FOUND));
+ }
+
+ @Test
+ @DisplayName("찜하지 않은 운동이면 BookmarkException(ALREADY_RELEASE_BOOKMARK)을 던진다")
+ void bookmarkNotFound_throwsBookmarkException() {
+ given(memberRepository.findById(member.getId())).willReturn(Optional.of(member));
+ given(exerciseRepository.findById(exercise.getId())).willReturn(Optional.of(exercise));
+ given(exerciseBookmarkRepository.findByMemberAndExercise(member, exercise))
+ .willReturn(Optional.empty());
+
+ assertThatThrownBy(() ->
+ bookmarkCommandService.releaseExerciseBookmark(member.getId(), exercise.getId()))
+ .isInstanceOf(BookmarkException.class)
+ .satisfies(e -> assertThat(((BookmarkException) e).getCode())
+ .isEqualTo(BookmarkErrorCode.ALREADY_RELEASE_BOOKMARK));
+
+ then(exerciseBookmarkRepository).should(never()).delete(any());
+ }
+ }
+ }
+}
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
new file mode 100644
index 000000000..75602159e
--- /dev/null
+++ b/src/test/java/umc/cockple/demo/domain/bookmark/service/BookmarkQueryServiceTest.java
@@ -0,0 +1,503 @@
+package umc.cockple.demo.domain.bookmark.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.converter.BookmarkConverter;
+import umc.cockple.demo.domain.bookmark.domain.ExerciseBookmark;
+import umc.cockple.demo.domain.bookmark.domain.PartyBookmark;
+import umc.cockple.demo.domain.bookmark.dto.GetAllExerciseBookmarksResponseDTO;
+import umc.cockple.demo.domain.bookmark.dto.GetAllPartyBookmarkResponseDTO;
+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.exercise.domain.Exercise;
+import umc.cockple.demo.domain.file.service.FileService;
+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.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.PartyOrderType;
+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.LocalDateTime;
+import java.time.LocalTime;
+import java.util.ArrayList;
+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.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+@ExtendWith(MockitoExtension.class)
+@DisplayName("BookmarkQueryService")
+class BookmarkQueryServiceTest {
+
+ @InjectMocks
+ private BookmarkQueryService bookmarkQueryService;
+
+ @Mock private ExerciseBookmarkRepository exerciseBookmarkRepository;
+ @Mock private PartyBookmarkRepository partyBookmarkRepository;
+ @Mock private MemberPartyRepository memberPartyRepository;
+ @Mock private MemberExerciseRepository memberExerciseRepository;
+ @Mock private MemberRepository memberRepository;
+ @Mock private BookmarkConverter bookmarkConverter;
+
+ private Member member;
+ private Party party;
+
+ @BeforeEach
+ void setUp() {
+ member = MemberFixture.createMember("테스트 유저", Gender.MALE, Level.A, 1001L);
+ ReflectionTestUtils.setField(member, "id", 1L);
+
+ party = PartyFixture.createParty("테스트 모임", member.getId(),
+ PartyFixture.createPartyAddr("서울특별시", "강남구"));
+ ReflectionTestUtils.setField(party, "id", 10L);
+ }
+
+ @Nested
+ @DisplayName("getAllExerciseBookmarks - 찜한 운동 목록 조회")
+ class GetAllExerciseBookmarks {
+
+ private Exercise oldExercise;
+ private Exercise newExercise;
+
+ @BeforeEach
+ void setUp() {
+ oldExercise = ExerciseFixture.createExerciseWithAddr(party, LocalDate.of(2099, 6, 1));
+ ReflectionTestUtils.setField(oldExercise, "id", 101L);
+
+ newExercise = ExerciseFixture.createExerciseWithAddr(party, LocalDate.of(2099, 12, 31));
+ ReflectionTestUtils.setField(newExercise, "id", 102L);
+ }
+
+ @Nested
+ @DisplayName("성공 케이스")
+ class Success {
+
+ @Test
+ @DisplayName("찜한 운동이 없으면 빈 목록을 반환한다")
+ void noBookmarks_returnsEmptyList() {
+ // given
+ given(memberRepository.findById(member.getId())).willReturn(Optional.of(member));
+ given(exerciseBookmarkRepository.findAllByMember(member)).willReturn(new ArrayList<>());
+ given(memberPartyRepository.findAllPartyIdsByMemberAndPartyIds(anyLong(), anyList()))
+ .willReturn(new ArrayList<>());
+ given(memberExerciseRepository.findAllExerciseIdsByMemberAndExerciseIds(anyLong(), anyList()))
+ .willReturn(new ArrayList<>());
+
+ // when
+ List result =
+ bookmarkQueryService.getAllExerciseBookmarks(member.getId(), BookmarkedExerciseOrderType.LATEST);
+
+ // then
+ assertThat(result).isEmpty();
+ }
+
+ @Test
+ @DisplayName("LATEST 정렬 시 최신순으로 반환한다")
+ void latestOrder_returnsNewestFirst() {
+ // given
+ ExerciseBookmark bookmarkOld = ExerciseBookmark.builder()
+ .member(member).exercise(oldExercise).build();
+ ReflectionTestUtils.setField(bookmarkOld, "createdAt", LocalDateTime.now().minusDays(2));
+
+ ExerciseBookmark bookmarkNew = ExerciseBookmark.builder()
+ .member(member).exercise(newExercise).build();
+ ReflectionTestUtils.setField(bookmarkNew, "createdAt", LocalDateTime.now().minusDays(1));
+
+ // 레포지토리에서 오래된 순서로 반환
+ List bookmarks = new ArrayList<>(List.of(bookmarkOld, bookmarkNew));
+
+ GetAllExerciseBookmarksResponseDTO dtoOld = GetAllExerciseBookmarksResponseDTO.builder()
+ .exerciseId(101L).partyName("테스트 모임").build();
+ GetAllExerciseBookmarksResponseDTO dtoNew = GetAllExerciseBookmarksResponseDTO.builder()
+ .exerciseId(102L).partyName("테스트 모임").build();
+
+ given(memberRepository.findById(member.getId())).willReturn(Optional.of(member));
+ given(exerciseBookmarkRepository.findAllByMember(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)))
+ .willReturn(dtoNew);
+ given(bookmarkConverter.exerciseBookmarkToDTO(eq(bookmarkOld), any(Boolean.class), any(Boolean.class)))
+ .willReturn(dtoOld);
+
+ // when
+ List result =
+ bookmarkQueryService.getAllExerciseBookmarks(member.getId(), BookmarkedExerciseOrderType.LATEST);
+
+ // then
+ assertThat(result).hasSize(2);
+ assertThat(result.get(0).exerciseId()).isEqualTo(102L); // 최신 것이 먼저
+ assertThat(result.get(1).exerciseId()).isEqualTo(101L);
+ }
+
+ @Test
+ @DisplayName("EARLIEST 정렬 시 오래된 순으로 반환한다")
+ void earliestOrder_returnsOldestFirst() {
+ // given
+ ExerciseBookmark bookmarkOld = ExerciseBookmark.builder()
+ .member(member).exercise(oldExercise).build();
+ ReflectionTestUtils.setField(bookmarkOld, "createdAt", LocalDateTime.now().minusDays(2));
+
+ ExerciseBookmark bookmarkNew = ExerciseBookmark.builder()
+ .member(member).exercise(newExercise).build();
+ ReflectionTestUtils.setField(bookmarkNew, "createdAt", LocalDateTime.now().minusDays(1));
+
+ // 레포지토리에서 최신 순서로 반환
+ List bookmarks = new ArrayList<>(List.of(bookmarkNew, bookmarkOld));
+
+ GetAllExerciseBookmarksResponseDTO dtoOld = GetAllExerciseBookmarksResponseDTO.builder()
+ .exerciseId(101L).partyName("테스트 모임").build();
+ GetAllExerciseBookmarksResponseDTO dtoNew = GetAllExerciseBookmarksResponseDTO.builder()
+ .exerciseId(102L).partyName("테스트 모임").build();
+
+ given(memberRepository.findById(member.getId())).willReturn(Optional.of(member));
+ given(exerciseBookmarkRepository.findAllByMember(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)))
+ .willReturn(dtoOld);
+ given(bookmarkConverter.exerciseBookmarkToDTO(eq(bookmarkNew), any(Boolean.class), any(Boolean.class)))
+ .willReturn(dtoNew);
+
+ // when
+ List result =
+ bookmarkQueryService.getAllExerciseBookmarks(member.getId(), BookmarkedExerciseOrderType.EARLIEST);
+
+ // then
+ assertThat(result).hasSize(2);
+ assertThat(result.get(0).exerciseId()).isEqualTo(101L); // 오래된 것이 먼저
+ assertThat(result.get(1).exerciseId()).isEqualTo(102L);
+ }
+
+ @Test
+ @DisplayName("includeParty, includeExercise 정보를 정확히 반영하여 변환한다")
+ void convertsBookmarkWithCorrectIncludeFlags() {
+ // given
+ Exercise exercise = ExerciseFixture.createExerciseWithAddr(party, LocalDate.of(2099, 12, 31));
+ ReflectionTestUtils.setField(exercise, "id", 101L);
+
+ ExerciseBookmark bookmark = ExerciseBookmark.builder()
+ .member(member).exercise(exercise).build();
+ ReflectionTestUtils.setField(bookmark, "createdAt", LocalDateTime.now());
+
+ GetAllExerciseBookmarksResponseDTO dto = GetAllExerciseBookmarksResponseDTO.builder()
+ .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(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);
+
+ // when
+ List result =
+ bookmarkQueryService.getAllExerciseBookmarks(member.getId(), BookmarkedExerciseOrderType.LATEST);
+
+ // then
+ assertThat(result).hasSize(1);
+ assertThat(result.get(0).includeParty()).isTrue();
+ assertThat(result.get(0).includeExercise()).isFalse();
+ }
+ }
+
+ @Nested
+ @DisplayName("실패 케이스")
+ class Failure {
+
+ @Test
+ @DisplayName("존재하지 않는 회원이면 MemberException(MEMBER_NOT_FOUND)을 던진다")
+ void memberNotFound_throwsMemberException() {
+ given(memberRepository.findById(999L)).willReturn(Optional.empty());
+
+ assertThatThrownBy(() ->
+ bookmarkQueryService.getAllExerciseBookmarks(999L, BookmarkedExerciseOrderType.LATEST))
+ .isInstanceOf(MemberException.class)
+ .satisfies(e -> assertThat(((MemberException) e).getCode())
+ .isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND));
+ }
+ }
+ }
+
+ @Nested
+ @DisplayName("getAllPartyBookmarks - 찜한 모임 목록 조회")
+ class GetAllPartyBookmarks {
+
+ @Nested
+ @DisplayName("성공 케이스")
+ class Success {
+
+ @Test
+ @DisplayName("찜한 모임이 없으면 빈 목록을 반환한다")
+ void noBookmarks_returnsEmptyList() {
+ // given
+ given(memberRepository.findById(member.getId())).willReturn(Optional.of(member));
+ given(partyBookmarkRepository.findAllByMemberWithParty(member)).willReturn(new ArrayList<>());
+
+ // when
+ List result =
+ bookmarkQueryService.getAllPartyBookmarks(member.getId(), PartyOrderType.LATEST);
+
+ // then
+ assertThat(result).isEmpty();
+ }
+
+ @Test
+ @DisplayName("LATEST 정렬 시 최신순으로 반환한다")
+ void latestOrder_returnsNewestFirst() {
+ // given
+ Party partyA = PartyFixture.createParty("모임A", member.getId(),
+ PartyFixture.createPartyAddr("서울특별시", "강남구"));
+ ReflectionTestUtils.setField(partyA, "id", 11L);
+
+ Party partyB = PartyFixture.createParty("모임B", member.getId(),
+ PartyFixture.createPartyAddr("서울특별시", "종로구"));
+ ReflectionTestUtils.setField(partyB, "id", 12L);
+
+ PartyBookmark bookmarkOld = PartyBookmark.builder()
+ .member(member).party(partyA)
+ .orderType(PartyOrderType.LATEST).build();
+ ReflectionTestUtils.setField(bookmarkOld, "createdAt", LocalDateTime.now().minusDays(2));
+
+ PartyBookmark bookmarkNew = PartyBookmark.builder()
+ .member(member).party(partyB)
+ .orderType(PartyOrderType.LATEST).build();
+ ReflectionTestUtils.setField(bookmarkNew, "createdAt", LocalDateTime.now().minusDays(1));
+
+ GetAllPartyBookmarkResponseDTO dtoA = GetAllPartyBookmarkResponseDTO.builder()
+ .partyId(11L).partyName("모임A").build();
+ GetAllPartyBookmarkResponseDTO dtoB = GetAllPartyBookmarkResponseDTO.builder()
+ .partyId(12L).partyName("모임B").build();
+
+ given(memberRepository.findById(member.getId())).willReturn(Optional.of(member));
+ given(partyBookmarkRepository.findAllByMemberWithParty(member))
+ .willReturn(new ArrayList<>(List.of(bookmarkOld, bookmarkNew)));
+ given(bookmarkConverter.partyBookmarkToDTO(eq(bookmarkNew), any(), any(), any()))
+ .willReturn(dtoB);
+ given(bookmarkConverter.partyBookmarkToDTO(eq(bookmarkOld), any(), any(), any()))
+ .willReturn(dtoA);
+
+ // when
+ List result =
+ bookmarkQueryService.getAllPartyBookmarks(member.getId(), PartyOrderType.LATEST);
+
+ // then
+ assertThat(result).hasSize(2);
+ assertThat(result.get(0).partyId()).isEqualTo(12L); // 최신 것이 먼저
+ assertThat(result.get(1).partyId()).isEqualTo(11L);
+ }
+
+ @Test
+ @DisplayName("OLDEST 정렬 시 오래된 순으로 반환한다")
+ void oldestOrder_returnsOldestFirst() {
+ // given
+ Party partyA = PartyFixture.createParty("모임A", member.getId(),
+ PartyFixture.createPartyAddr("서울특별시", "강남구"));
+ ReflectionTestUtils.setField(partyA, "id", 11L);
+
+ Party partyB = PartyFixture.createParty("모임B", member.getId(),
+ PartyFixture.createPartyAddr("서울특별시", "종로구"));
+ ReflectionTestUtils.setField(partyB, "id", 12L);
+
+ PartyBookmark bookmarkOld = PartyBookmark.builder()
+ .member(member).party(partyA)
+ .orderType(PartyOrderType.OLDEST).build();
+ ReflectionTestUtils.setField(bookmarkOld, "createdAt", LocalDateTime.now().minusDays(2));
+
+ PartyBookmark bookmarkNew = PartyBookmark.builder()
+ .member(member).party(partyB)
+ .orderType(PartyOrderType.OLDEST).build();
+ ReflectionTestUtils.setField(bookmarkNew, "createdAt", LocalDateTime.now().minusDays(1));
+
+ GetAllPartyBookmarkResponseDTO dtoA = GetAllPartyBookmarkResponseDTO.builder()
+ .partyId(11L).partyName("모임A").build();
+ GetAllPartyBookmarkResponseDTO dtoB = GetAllPartyBookmarkResponseDTO.builder()
+ .partyId(12L).partyName("모임B").build();
+
+ given(memberRepository.findById(member.getId())).willReturn(Optional.of(member));
+ given(partyBookmarkRepository.findAllByMemberWithParty(member))
+ .willReturn(new ArrayList<>(List.of(bookmarkNew, bookmarkOld)));
+ given(bookmarkConverter.partyBookmarkToDTO(eq(bookmarkOld), any(), any(), any()))
+ .willReturn(dtoA);
+ given(bookmarkConverter.partyBookmarkToDTO(eq(bookmarkNew), any(), any(), any()))
+ .willReturn(dtoB);
+
+ // when
+ List result =
+ bookmarkQueryService.getAllPartyBookmarks(member.getId(), PartyOrderType.OLDEST);
+
+ // then
+ assertThat(result).hasSize(2);
+ assertThat(result.get(0).partyId()).isEqualTo(11L); // 오래된 것이 먼저
+ assertThat(result.get(1).partyId()).isEqualTo(12L);
+ }
+
+ @Test
+ @DisplayName("EXERCISE_COUNT 정렬 시 운동 횟수 많은 순으로 반환한다")
+ void exerciseCountOrder_returnsMostExercisedFirst() {
+ // given
+ Party partyLow = PartyFixture.createParty("운동 적은 모임", member.getId(),
+ PartyFixture.createPartyAddr("서울특별시", "강남구"));
+ ReflectionTestUtils.setField(partyLow, "id", 11L);
+ ReflectionTestUtils.setField(partyLow, "exerciseCount", 2);
+
+ Party partyHigh = PartyFixture.createParty("운동 많은 모임", member.getId(),
+ PartyFixture.createPartyAddr("서울특별시", "종로구"));
+ ReflectionTestUtils.setField(partyHigh, "id", 12L);
+ ReflectionTestUtils.setField(partyHigh, "exerciseCount", 10);
+
+ PartyBookmark bookmarkLow = PartyBookmark.builder()
+ .member(member).party(partyLow)
+ .orderType(PartyOrderType.EXERCISE_COUNT).build();
+ ReflectionTestUtils.setField(bookmarkLow, "createdAt", LocalDateTime.now().minusDays(1));
+
+ PartyBookmark bookmarkHigh = PartyBookmark.builder()
+ .member(member).party(partyHigh)
+ .orderType(PartyOrderType.EXERCISE_COUNT).build();
+ ReflectionTestUtils.setField(bookmarkHigh, "createdAt", LocalDateTime.now().minusDays(2));
+
+ GetAllPartyBookmarkResponseDTO dtoLow = GetAllPartyBookmarkResponseDTO.builder()
+ .partyId(11L).partyName("운동 적은 모임").exerciseCnt(2).build();
+ GetAllPartyBookmarkResponseDTO dtoHigh = GetAllPartyBookmarkResponseDTO.builder()
+ .partyId(12L).partyName("운동 많은 모임").exerciseCnt(10).build();
+
+ given(memberRepository.findById(member.getId())).willReturn(Optional.of(member));
+ given(partyBookmarkRepository.findAllByMemberWithParty(member))
+ .willReturn(new ArrayList<>(List.of(bookmarkLow, bookmarkHigh)));
+ given(bookmarkConverter.partyBookmarkToDTO(eq(bookmarkHigh), any(), any(), any()))
+ .willReturn(dtoHigh);
+ given(bookmarkConverter.partyBookmarkToDTO(eq(bookmarkLow), any(), any(), any()))
+ .willReturn(dtoLow);
+
+ // when
+ List result =
+ bookmarkQueryService.getAllPartyBookmarks(member.getId(), PartyOrderType.EXERCISE_COUNT);
+
+ // then
+ assertThat(result).hasSize(2);
+ assertThat(result.get(0).partyId()).isEqualTo(12L); // 운동 많은 모임이 먼저
+ assertThat(result.get(1).partyId()).isEqualTo(11L);
+ }
+
+ @Test
+ @DisplayName("파티에 미래 운동이 있을 때 가장 가까운 운동 정보를 함께 반환한다")
+ void partyWithFutureExercise_returnsLatestExerciseInfo() {
+ // given
+ Exercise futureExercise = ExerciseFixture.createExercise(party,
+ LocalDate.now().plusDays(7), LocalTime.of(10, 0), true, false);
+ party.addExercise(futureExercise);
+
+ PartyBookmark bookmark = PartyBookmark.builder()
+ .member(member).party(party)
+ .orderType(umc.cockple.demo.domain.party.enums.PartyOrderType.LATEST).build();
+ ReflectionTestUtils.setField(bookmark, "createdAt", LocalDateTime.now());
+
+ GetAllPartyBookmarkResponseDTO dto = GetAllPartyBookmarkResponseDTO.builder()
+ .partyId(party.getId())
+ .latestExerciseDate(LocalDate.now().plusDays(7))
+ .latestExerciseTime(ActivityTime.MORNING)
+ .build();
+
+ given(memberRepository.findById(member.getId())).willReturn(Optional.of(member));
+ given(partyBookmarkRepository.findAllByMemberWithParty(member))
+ .willReturn(new ArrayList<>(List.of(bookmark)));
+ given(bookmarkConverter.partyBookmarkToDTO(eq(bookmark),
+ eq(futureExercise), eq(ActivityTime.MORNING), any()))
+ .willReturn(dto);
+
+ // when
+ List result =
+ bookmarkQueryService.getAllPartyBookmarks(member.getId(), PartyOrderType.LATEST);
+
+ // then
+ assertThat(result).hasSize(1);
+ assertThat(result.get(0).latestExerciseDate()).isEqualTo(LocalDate.now().plusDays(7));
+ assertThat(result.get(0).latestExerciseTime()).isEqualTo(ActivityTime.MORNING);
+ }
+
+ @Test
+ @DisplayName("파티에 미래 운동이 없을 때 exercise는 null로 변환된다")
+ void partyWithNoFutureExercise_passesNullExercise() {
+ // given - 과거 운동만 있는 파티
+ Exercise pastExercise = ExerciseFixture.createExercise(party,
+ LocalDate.now().minusDays(1), LocalTime.of(10, 0), true, false);
+ party.addExercise(pastExercise);
+
+ PartyBookmark bookmark = PartyBookmark.builder()
+ .member(member).party(party)
+ .orderType(umc.cockple.demo.domain.party.enums.PartyOrderType.LATEST).build();
+ ReflectionTestUtils.setField(bookmark, "createdAt", LocalDateTime.now());
+
+ GetAllPartyBookmarkResponseDTO dto = GetAllPartyBookmarkResponseDTO.builder()
+ .partyId(party.getId())
+ .latestExerciseDate(null)
+ .latestExerciseTime(null)
+ .build();
+
+ given(memberRepository.findById(member.getId())).willReturn(Optional.of(member));
+ given(partyBookmarkRepository.findAllByMemberWithParty(member))
+ .willReturn(new ArrayList<>(List.of(bookmark)));
+ given(bookmarkConverter.partyBookmarkToDTO(eq(bookmark), eq(null), eq(null), any()))
+ .willReturn(dto);
+
+ // when
+ List result =
+ bookmarkQueryService.getAllPartyBookmarks(member.getId(), PartyOrderType.LATEST);
+
+ // then
+ assertThat(result).hasSize(1);
+ // null exercise 로 converter가 호출되었는지 검증
+ verify(bookmarkConverter, times(1))
+ .partyBookmarkToDTO(eq(bookmark), eq(null), eq(null), any());
+ }
+ }
+
+ @Nested
+ @DisplayName("실패 케이스")
+ class Failure {
+
+ @Test
+ @DisplayName("존재하지 않는 회원이면 MemberException(MEMBER_NOT_FOUND)을 던진다")
+ void memberNotFound_throwsMemberException() {
+ given(memberRepository.findById(999L)).willReturn(Optional.empty());
+
+ assertThatThrownBy(() ->
+ bookmarkQueryService.getAllPartyBookmarks(999L, PartyOrderType.LATEST))
+ .isInstanceOf(MemberException.class)
+ .satisfies(e -> assertThat(((MemberException) e).getCode())
+ .isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND));
+ }
+ }
+ }
+}
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 5bda35a46..4d3643739 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
@@ -4,6 +4,7 @@
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.web.servlet.MockMvc;
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.domain.ChatRoomMember;
import umc.cockple.demo.domain.chat.exception.ChatErrorCode;
@@ -28,6 +29,7 @@
import static org.hamcrest.Matchers.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -56,8 +58,8 @@ void setUp() {
PartyAddr addr = partyAddrRepository.save(PartyFixture.createPartyAddr("서울특별시", "강남구"));
party = partyRepository.save(PartyFixture.createParty("배드민턴 모임", member.getId(), addr));
- memberPartyRepository.save(MemberFixture.createMemberParty(party, member, Role.party_MANAGER));
- memberPartyRepository.save(MemberFixture.createMemberParty(party, otherMember, Role.party_MEMBER));
+ memberPartyRepository.save(MemberFixture.createMemberParty(party, member, Role.PARTY_MANAGER));
+ memberPartyRepository.save(MemberFixture.createMemberParty(party, otherMember, Role.PARTY_MEMBER));
partyChatRoom = chatRoomRepository.save(ChatFixture.createPartyChatRoom(party));
directChatRoom = chatRoomRepository.save(ChatFixture.createDirectChatRoom());
@@ -125,6 +127,8 @@ void partyChatRoom_fullFieldValidation() throws Exception {
.andExpect(jsonPath("$.data.messages[0].timestamp").exists())
.andExpect(jsonPath("$.data.messages[0].isMyMessage").value(true))
.andExpect(jsonPath("$.data.messages[0].isSenderWithdrawn").value(false))
+ .andExpect(jsonPath("$.data.messages[0].images").isArray())
+ .andExpect(jsonPath("$.data.messages[0].images", hasSize(0)))
// 3. participants 리스트 및 첫 번째 참여자 필드 전수 검사
.andExpect(jsonPath("$.data.participants").isArray())
.andExpect(jsonPath("$.data.participants[0].memberId").value(member.getId()))
@@ -232,6 +236,35 @@ void withdrawnSender_isSenderWithdrawnTrue() throws Exception {
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.messages[0].isSenderWithdrawn").value(true));
}
+
+ @Test
+ @DisplayName("200 - 이미지 메시지 조회 시 images 필드에 파일 정보가 포함된다")
+ void imageMessage_containsFileInfo() throws Exception {
+ chatRoomMemberRepository.save(ChatRoomMember.create(partyChatRoom, member));
+
+ ChatMessage imageMessage = chatMessageRepository.save(
+ ChatFixture.createImageMessage(partyChatRoom, member, java.util.List.of()));
+
+ ChatMessageFile file1 = ChatMessageFile.create(imageMessage, "chat/img1.png", 1, "photo1.png", 1024L, "image/png");
+ ChatMessageFile file2 = ChatMessageFile.create(imageMessage, "chat/img2.png", 2, "photo2.png", 2048L, "image/png");
+ imageMessage.getChatMessageFiles().addAll(java.util.List.of(file1, file2));
+ chatMessageRepository.saveAndFlush(imageMessage);
+
+ SecurityContextHelper.setAuthentication(member.getId(), member.getNickname());
+
+ mockMvc.perform(get("/api/chats/rooms/{roomId}", partyChatRoom.getId()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.messages[0].messageType").value("TEXT"))
+ .andExpect(jsonPath("$.data.messages[0].images").isArray())
+ .andExpect(jsonPath("$.data.messages[0].images", hasSize(2)))
+ .andExpect(jsonPath("$.data.messages[0].images[0].imgOrder").value(1))
+ .andExpect(jsonPath("$.data.messages[0].images[0].originalFileName").value("photo1.png"))
+ .andExpect(jsonPath("$.data.messages[0].images[0].fileSize").value(1024))
+ .andExpect(jsonPath("$.data.messages[0].images[0].fileType").value("image/png"))
+ .andExpect(jsonPath("$.data.messages[0].images[0].isEmoji").value(false))
+ .andExpect(jsonPath("$.data.messages[0].images[1].imgOrder").value(2))
+ .andExpect(jsonPath("$.data.messages[0].images[1].originalFileName").value("photo2.png"));
+ }
}
@Nested
@@ -305,7 +338,9 @@ void getChatMessages_fullFieldValidation() throws Exception {
.andExpect(jsonPath("$.data.messages[0].messageType").value("TEXT"))
.andExpect(jsonPath("$.data.messages[0].timestamp").exists())
.andExpect(jsonPath("$.data.messages[0].isMyMessage").value(true))
- .andExpect(jsonPath("$.data.messages[0].isSenderWithdrawn").value(false));
+ .andExpect(jsonPath("$.data.messages[0].isSenderWithdrawn").value(false))
+ .andExpect(jsonPath("$.data.messages[0].images").isArray())
+ .andExpect(jsonPath("$.data.messages[0].images", hasSize(0)));
}
@Test
@@ -442,6 +477,39 @@ void withdrawnSender_isSenderWithdrawnTrue() throws Exception {
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.messages[0].isSenderWithdrawn").value(true));
}
+
+ @Test
+ @DisplayName("200 - 이미지 메시지 조회 시 images 필드에 파일 정보가 포함된다")
+ void imageMessage_containsFileInfo() throws Exception {
+ chatRoomMemberRepository.save(ChatRoomMember.create(partyChatRoom, member));
+
+ ChatMessage imageMessage = chatMessageRepository.save(
+ ChatFixture.createImageMessage(partyChatRoom, member, java.util.List.of()));
+
+ ChatMessageFile file1 = ChatMessageFile.create(imageMessage, "chat/img1.png", 1, "photo1.png", 1024L, "image/png");
+ ChatMessageFile file2 = ChatMessageFile.create(imageMessage, "chat/img2.png", 2, "photo2.png", 2048L, "image/png");
+ imageMessage.getChatMessageFiles().addAll(java.util.List.of(file1, file2));
+ chatMessageRepository.saveAndFlush(imageMessage);
+
+ Long cursor = imageMessage.getId() + 1;
+
+ SecurityContextHelper.setAuthentication(member.getId(), member.getNickname());
+
+ mockMvc.perform(get("/api/chats/rooms/{roomId}/messages/previous", partyChatRoom.getId())
+ .param("cursor", cursor.toString())
+ .param("size", "10"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.messages[0].messageType").value("TEXT"))
+ .andExpect(jsonPath("$.data.messages[0].images").isArray())
+ .andExpect(jsonPath("$.data.messages[0].images", hasSize(2)))
+ .andExpect(jsonPath("$.data.messages[0].images[0].imgOrder").value(1))
+ .andExpect(jsonPath("$.data.messages[0].images[0].originalFileName").value("photo1.png"))
+ .andExpect(jsonPath("$.data.messages[0].images[0].fileSize").value(1024))
+ .andExpect(jsonPath("$.data.messages[0].images[0].fileType").value("image/png"))
+ .andExpect(jsonPath("$.data.messages[0].images[0].isEmoji").value(false))
+ .andExpect(jsonPath("$.data.messages[0].images[1].imgOrder").value(2))
+ .andExpect(jsonPath("$.data.messages[0].images[1].originalFileName").value("photo2.png"));
+ }
}
@Nested
@@ -463,4 +531,89 @@ void notChatRoomMember() throws Exception {
}
}
}
+
+ @Nested
+ @DisplayName("POST /api/chats/files/{fileId}/download-token - 파일 다운로드 토큰 발급")
+ class IssueDownloadToken {
+
+ private ChatMessageFile chatFile;
+
+ @BeforeEach
+ void setUpFile() {
+ chatRoomMemberRepository.save(ChatRoomMember.create(partyChatRoom, member));
+ ChatMessage message = chatMessageRepository.save(ChatFixture.createTextMessage(partyChatRoom, member, "이미지 첨부"));
+ chatFile = ChatMessageFile.create(message, "test/key.webp", 1, "test.webp", 100L, "image/webp");
+ message.getChatMessageFiles().add(chatFile);
+ message = chatMessageRepository.saveAndFlush(message);
+ chatFile = message.getChatMessageFiles().get(0);
+ }
+
+ @Nested
+ @DisplayName("성공 케이스")
+ class Success {
+
+ @Test
+ @DisplayName("200 - 채팅방 권한이 있는 멤버는 토큰을 성공적으로 발급받는다")
+ void success_issueToken() throws Exception {
+ SecurityContextHelper.setAuthentication(member.getId(), member.getNickname());
+
+ mockMvc.perform(post("/api/chats/files/{fileId}/download-token", chatFile.getId()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.downloadToken").isString())
+ .andExpect(jsonPath("$.data.expiresAt").exists());
+ }
+ }
+
+ @Nested
+ @DisplayName("실패 케이스")
+ class Failure {
+
+ @Test
+ @DisplayName("400 - 채팅방에 참여하지 않은 사용자가 토큰을 요청하면 접근 거부 에러를 반환한다")
+ void fail_notRoomMember() throws Exception {
+ SecurityContextHelper.setAuthentication(otherMember.getId(), otherMember.getNickname());
+
+ mockMvc.perform(post("/api/chats/files/{fileId}/download-token", chatFile.getId()))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED.getCode()));
+ }
+
+ @Test
+ @DisplayName("404 - 존재하지 않는 파일 ID로 요청하면 파일 없음 에러를 반환한다")
+ void fail_fileNotFound() throws Exception {
+ SecurityContextHelper.setAuthentication(member.getId(), member.getNickname());
+
+ mockMvc.perform(post("/api/chats/files/{fileId}/download-token", 99999L))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.code").value(ChatErrorCode.FILE_NOT_FOUND.getCode()));
+ }
+ }
+ }
+
+ @Nested
+ @DisplayName("GET /api/chats/files/{fileId}/download - 실제 파일 다운로드")
+ class DownloadFile {
+
+ @Nested
+ @DisplayName("실패 케이스")
+ class Failure {
+
+ @Test
+ @DisplayName("403 - 존재하지 않거나 유효하지 않은 토큰으로 접근하면 403 인증 에러 반환")
+ void fail_invalidToken() throws Exception {
+ chatRoomMemberRepository.save(ChatRoomMember.create(partyChatRoom, member));
+ ChatMessage message = chatMessageRepository.save(ChatFixture.createTextMessage(partyChatRoom, member, "테스트"));
+ ChatMessageFile chatFile = ChatMessageFile.create(message, "test/key.webp", 1, "test.webp", 100L, "image/webp");
+ message.getChatMessageFiles().add(chatFile);
+ message = chatMessageRepository.saveAndFlush(message);
+ chatFile = message.getChatMessageFiles().get(0);
+
+ SecurityContextHelper.setAuthentication(member.getId(), member.getNickname());
+
+ mockMvc.perform(get("/api/chats/files/{fileId}/download", chatFile.getId()).param("token", "invalid-fake-token"))
+ .andExpect(status().isForbidden())
+ .andExpect(jsonPath("$.code").value(ChatErrorCode.INVALID_DOWNLOAD_TOKEN.getCode()));
+ }
+ }
+ }
}
diff --git a/src/test/java/umc/cockple/demo/domain/chat/service/ChatFileServiceTest.java b/src/test/java/umc/cockple/demo/domain/chat/service/ChatFileServiceTest.java
new file mode 100644
index 000000000..b04fb5d53
--- /dev/null
+++ b/src/test/java/umc/cockple/demo/domain/chat/service/ChatFileServiceTest.java
@@ -0,0 +1,163 @@
+package umc.cockple.demo.domain.chat.service;
+
+import com.google.cloud.storage.Blob;
+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.core.io.Resource;
+import org.springframework.http.ResponseEntity;
+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.ChatMessageFile;
+import umc.cockple.demo.domain.chat.domain.ChatRoom;
+import umc.cockple.demo.domain.chat.domain.DownloadToken;
+import umc.cockple.demo.domain.chat.dto.ChatDownloadTokenDTO;
+import umc.cockple.demo.domain.chat.exception.ChatErrorCode;
+import umc.cockple.demo.domain.chat.exception.ChatException;
+import umc.cockple.demo.domain.chat.repository.ChatFileRepository;
+import umc.cockple.demo.domain.chat.repository.ChatRoomMemberRepository;
+import umc.cockple.demo.domain.chat.repository.DownloadTokenRepository;
+import umc.cockple.demo.domain.file.service.FileService;
+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.time.LocalDateTime;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+@ExtendWith(MockitoExtension.class)
+@DisplayName("ChatFileService 단위 테스트")
+class ChatFileServiceTest {
+ @Mock private ChatFileRepository chatFileRepository;
+ @Mock private DownloadTokenRepository downloadTokenRepository;
+ @Mock private ChatRoomMemberRepository chatRoomMemberRepository;
+ @Mock private FileService fileService;
+
+ private ChatFileService chatFileService;
+
+ private ChatRoom chatRoom;
+ private ChatMessage message;
+ private ChatMessageFile chatFile;
+
+ @BeforeEach
+ void setUp() {
+ ChatConverter chatConverter = new ChatConverter();
+ chatFileService = new ChatFileServiceImpl(
+ chatFileRepository,
+ downloadTokenRepository,
+ chatConverter,
+ chatRoomMemberRepository,
+ fileService
+ );
+ Member sender = MemberFixture.createMemberWithName("홍길동", "길동", Gender.MALE, Level.A, 1001L);
+ ReflectionTestUtils.setField(sender, "id", 1L);
+
+ var party = PartyFixture.createParty("모임", sender.getId(), PartyFixture.createPartyAddr("서울", "강남구"));
+ ReflectionTestUtils.setField(party, "id", 100L);
+
+ chatRoom = ChatFixture.createPartyChatRoom(party);
+ ReflectionTestUtils.setField(chatRoom, "id", 10L);
+
+ message = ChatFixture.createTextMessage(chatRoom, sender, "파일 첨부");
+ ReflectionTestUtils.setField(message, "id", 50L);
+
+ chatFile = ChatMessageFile.create(message, "test/key.webp", 1, "test.webp", 100L, "image/webp");
+ ReflectionTestUtils.setField(chatFile, "id", 100L);
+ }
+
+ // ========== issueDownloadToken ==========
+ @Nested
+ @DisplayName("issueDownloadToken - 다운로드 토큰 발급 테스트")
+ class IssueDownloadToken {
+
+ @Test
+ @DisplayName("채팅방 권한이 있는 멤버는 토큰을 성공적으로 발급받는다")
+ void success() {
+ given(chatFileRepository.findById(100L)).willReturn(Optional.of(chatFile));
+ given(chatRoomMemberRepository.existsByChatRoomIdAndMemberId(10L, 1L)).willReturn(true);
+
+ ChatDownloadTokenDTO.Response response = chatFileService.issueDownloadToken(100L, 1L);
+
+ assertThat(response).isNotNull();
+ assertThat(response.downloadToken()).isNotBlank();
+ verify(downloadTokenRepository).save(any(DownloadToken.class));
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 파일 ID를 요청하면 FILE_NOT_FOUND 예외가 발생한다")
+ void throwExceptionWhenFileNotFound() {
+ given(chatFileRepository.findById(999L)).willReturn(Optional.empty());
+
+ ChatException exception = assertThrows(ChatException.class, () -> chatFileService.issueDownloadToken(999L, 1L));
+
+ assertThat(exception.getCode()).isEqualTo(ChatErrorCode.FILE_NOT_FOUND);
+ }
+
+ @Test
+ @DisplayName("채팅방 멤버가 아닌 경우 권한 부족으로 CHAT_ROOM_ACCESS_DENIED 예외가 발생한다")
+ void throwExceptionWhenAccessDenied() {
+ given(chatFileRepository.findById(100L)).willReturn(Optional.of(chatFile));
+ given(chatRoomMemberRepository.existsByChatRoomIdAndMemberId(10L, 2L)).willReturn(false);
+
+ ChatException exception = assertThrows(
+ ChatException.class, () -> chatFileService.issueDownloadToken(100L, 2L)
+ );
+ assertThat(exception.getCode()).isEqualTo(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED);
+ }
+ }
+
+ // ========== downloadFile ==========
+ @Nested
+ @DisplayName("downloadFile - 실제 파일 다운로드 테스트")
+ class DownloadFile {
+
+ @Test
+ @DisplayName("유효한 토큰 사용 시 Blob 데이터를 기반으로 정상적으로 ResponseEntity를 반환한다")
+ void success() {
+ DownloadToken token = DownloadToken.create(100L, 1L, 180);
+ ReflectionTestUtils.setField(token, "expiresAt", LocalDateTime.now().plusMinutes(5));
+ given(downloadTokenRepository.findByToken("valid-token")).willReturn(Optional.of(token));
+ given(chatFileRepository.findById(100L)).willReturn(Optional.of(chatFile));
+ Blob mockBlob = mock(Blob.class);
+ given(mockBlob.getSize()).willReturn(1024L);
+ given(mockBlob.getContentType()).willReturn("image/webp");
+ given(mockBlob.getContent()).willReturn(new byte[1024]);
+ given(fileService.downloadFile("test/key.webp")).willReturn(mockBlob);
+
+ ResponseEntity response = chatFileService.downloadFile(100L, "valid-token");
+
+ assertThat(response.getStatusCodeValue()).isEqualTo(200);
+ assertThat(response.getHeaders().getContentLength()).isEqualTo(1024L);
+ assertThat(response.getHeaders().getContentType().toString()).isEqualTo("image/webp");
+ verify(downloadTokenRepository).delete(token);
+ }
+
+ @Test
+ @DisplayName("DB에 존재하지 않거나 만료된 토큰의 경우 INVALID_DOWNLOAD_TOKEN 예외가 발생한다")
+ void throwExceptionWhenInvalidToken() {
+ DownloadToken token = DownloadToken.create(100L, 1L, 0);
+ ReflectionTestUtils.setField(token, "expiresAt", LocalDateTime.now().minusMinutes(5)); // 만료된 토큰
+ given(downloadTokenRepository.findByToken("expired-token")).willReturn(Optional.of(token));
+
+ ChatException exception = assertThrows(ChatException.class, () ->
+ chatFileService.downloadFile(100L, "expired-token")
+ );
+ assertThat(exception.getCode()).isEqualTo(ChatErrorCode.INVALID_DOWNLOAD_TOKEN);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/umc/cockple/demo/domain/chat/service/ChatProcessorTest.java b/src/test/java/umc/cockple/demo/domain/chat/service/ChatProcessorTest.java
index 7bfbf7fae..fe301689b 100644
--- a/src/test/java/umc/cockple/demo/domain/chat/service/ChatProcessorTest.java
+++ b/src/test/java/umc/cockple/demo/domain/chat/service/ChatProcessorTest.java
@@ -10,11 +10,11 @@
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.ChatMessageImg;
+import umc.cockple.demo.domain.chat.domain.ChatMessageFile;
import umc.cockple.demo.domain.chat.domain.ChatRoom;
import umc.cockple.demo.domain.chat.dto.ChatCommonDTO;
import umc.cockple.demo.domain.chat.enums.MessageType;
-import umc.cockple.demo.domain.image.service.ImageService;
+import umc.cockple.demo.domain.file.service.FileService;
import umc.cockple.demo.domain.member.domain.Member;
import umc.cockple.demo.domain.member.domain.ProfileImg;
import umc.cockple.demo.global.enums.Gender;
@@ -35,7 +35,7 @@
class ChatProcessorTest {
@Mock
- private ImageService imageService;
+ private FileService fileService;
private ChatConverter chatConverter;
private ChatProcessor chatProcessor;
@@ -46,7 +46,7 @@ class ChatProcessorTest {
@BeforeEach
void setUp() {
chatConverter = new ChatConverter();
- chatProcessor = new ChatProcessor(imageService, chatConverter);
+ chatProcessor = new ChatProcessor(fileService, chatConverter);
sender = MemberFixture.createMemberWithName("홍길동", "길동", Gender.MALE, Level.A, 1001L);
ReflectionTestUtils.setField(sender, "id", 10L);
@@ -69,7 +69,7 @@ void returnsNull_whenProfileImgIsNull() {
String result = chatProcessor.generateProfileImageUrl(null);
assertThat(result).isNull();
- verify(imageService, never()).getUrlFromKey(null);
+ verify(fileService, never()).getUrlFromKey(null);
}
@Test
@@ -82,7 +82,7 @@ void returnsNull_whenImgKeyIsNull() {
String result = chatProcessor.generateProfileImageUrl(profileImg);
assertThat(result).isNull();
- verify(imageService, never()).getUrlFromKey(null);
+ verify(fileService, never()).getUrlFromKey(null);
}
@Test
@@ -95,7 +95,7 @@ void returnsNull_whenImgKeyIsBlank() {
String result = chatProcessor.generateProfileImageUrl(profileImg);
assertThat(result).isNull();
- verify(imageService, never()).getUrlFromKey(" ");
+ verify(fileService, never()).getUrlFromKey(" ");
}
@Test
@@ -105,82 +105,82 @@ void returnsUrl_whenImgKeyIsValid() {
.imgKey("profile/key123.jpg")
.build();
- given(imageService.getUrlFromKey("profile/key123.jpg"))
+ given(fileService.getUrlFromKey("profile/key123.jpg"))
.willReturn("https://cdn.example.com/profile/key123.jpg");
String result = chatProcessor.generateProfileImageUrl(profileImg);
assertThat(result).isEqualTo("https://cdn.example.com/profile/key123.jpg");
- verify(imageService).getUrlFromKey("profile/key123.jpg");
+ verify(fileService).getUrlFromKey("profile/key123.jpg");
}
}
- // ========== generateImageUrl ==========
+ // ========== generateFileUrl ==========
@Nested
- @DisplayName("generateImageUrl - 채팅 이미지 URL 생성")
- class GenerateImageUrl {
+ @DisplayName("generateFileUrl - 채팅 파일 URL 생성")
+ class GenerateFileUrl {
@Test
- @DisplayName("img가 null이면 null을 반환한다")
- void returnsNull_whenImgIsNull() {
- String result = chatProcessor.generateImageUrl(null);
+ @DisplayName("file이 null이면 null을 반환한다")
+ void returnsNull_whenFileIsNull() {
+ String result = chatProcessor.generateFileUrl(null);
assertThat(result).isNull();
}
@Test
- @DisplayName("imgKey가 null이면 null을 반환하고 imageService를 호출하지 않는다")
- void returnsNull_whenImgKeyIsNull() {
- ChatMessageImg img = ChatMessageImg.builder()
- .imgKey(null)
- .imgOrder(1)
+ @DisplayName("fileKey가 null이면 null을 반환하고 fileService를 호출하지 않는다")
+ void returnsNull_whenFileKeyIsNull() {
+ ChatMessageFile img = ChatMessageFile.builder()
+ .fileKey(null)
+ .fileOrder(1)
.originalFileName("photo.jpg")
.fileSize(1024L)
.fileType("image/jpeg")
.build();
- String result = chatProcessor.generateImageUrl(img);
+ String result = chatProcessor.generateFileUrl(img);
assertThat(result).isNull();
- verify(imageService, never()).getUrlFromKey(null);
+ verify(fileService, never()).getUrlFromKey(null);
}
@Test
- @DisplayName("imgKey가 공백 문자열이면 null을 반환하고 imageService를 호출하지 않는다")
- void returnsNull_whenImgKeyIsBlank() {
- ChatMessageImg img = ChatMessageImg.builder()
- .imgKey(" ")
- .imgOrder(1)
+ @DisplayName("fileKey가 공백 문자열이면 null을 반환하고 fileService를 호출하지 않는다")
+ void returnsNull_whenFileKeyIsBlank() {
+ ChatMessageFile img = ChatMessageFile.builder()
+ .fileKey(" ")
+ .fileOrder(1)
.originalFileName("photo.jpg")
.fileSize(1024L)
.fileType("image/jpeg")
.build();
- String result = chatProcessor.generateImageUrl(img);
+ String result = chatProcessor.generateFileUrl(img);
assertThat(result).isNull();
- verify(imageService, never()).getUrlFromKey(" ");
+ verify(fileService, never()).getUrlFromKey(" ");
}
@Test
- @DisplayName("유효한 imgKey가 있으면 imageService로 URL을 생성해서 반환한다")
- void returnsUrl_whenImgKeyIsValid() {
- ChatMessageImg img = ChatMessageImg.builder()
- .imgKey("chat/img456.jpg")
- .imgOrder(1)
+ @DisplayName("유효한 fileKey가 있으면 fileService로 URL을 생성해서 반환한다")
+ void returnsUrl_whenFileKeyIsValid() {
+ ChatMessageFile img = ChatMessageFile.builder()
+ .fileKey("chat/img456.jpg")
+ .fileOrder(1)
.originalFileName("photo.jpg")
.fileSize(2048L)
.fileType("image/jpeg")
.build();
- given(imageService.getUrlFromKey("chat/img456.jpg"))
+ given(fileService.getUrlFromKey("chat/img456.jpg"))
.willReturn("https://cdn.example.com/chat/img456.jpg");
- String result = chatProcessor.generateImageUrl(img);
+ String result = chatProcessor.generateFileUrl(img);
assertThat(result).isEqualTo("https://cdn.example.com/chat/img456.jpg");
- verify(imageService).getUrlFromKey("chat/img456.jpg");
+ verify(fileService).getUrlFromKey("chat/img456.jpg");
}
}
@@ -277,7 +277,7 @@ void senderProfileImageUrl_isNull_whenNoProfileImg() {
List result = chatProcessor.processMessages(sender.getId(), List.of(message));
assertThat(result.get(0).senderProfileImageUrl()).isNull();
- verify(imageService, never()).getUrlFromKey(null);
+ verify(fileService, never()).getUrlFromKey(null);
}
@Test
@@ -305,44 +305,44 @@ void multipleMessages_preserveInputOrder() {
}
@Test
- @DisplayName("이미지가 포함된 메시지는 imgOrder 오름차순으로 정렬된다")
- void messageImages_areSortedByImgOrder() {
+ @DisplayName("이미지가 포함된 메시지는 fileOrder 오름차순으로 정렬된다")
+ void messageImages_areSortedByFileOrder() {
ChatMessage message = ChatFixture.createTextMessage(chatRoom, sender, "이미지 메시지");
ReflectionTestUtils.setField(message, "id", 1L);
- // imgOrder 역순으로 삽입: 3 → 1 → 2
- ChatMessageImg img3 = ChatMessageImg.builder()
- .imgKey("img/third.jpg")
- .imgOrder(3)
+ // fileOrder 역순으로 삽입: 3 → 1 → 2
+ ChatMessageFile img3 = ChatMessageFile.builder()
+ .fileKey("img/third.jpg")
+ .fileOrder(3)
.originalFileName("third.jpg")
.fileSize(100L)
.fileType("image/jpeg")
.build();
- ChatMessageImg img1 = ChatMessageImg.builder()
- .imgKey("img/first.jpg")
- .imgOrder(1)
+ ChatMessageFile img1 = ChatMessageFile.builder()
+ .fileKey("img/first.jpg")
+ .fileOrder(1)
.originalFileName("first.jpg")
.fileSize(100L)
.fileType("image/jpeg")
.build();
- ChatMessageImg img2 = ChatMessageImg.builder()
- .imgKey("img/second.jpg")
- .imgOrder(2)
+ ChatMessageFile img2 = ChatMessageFile.builder()
+ .fileKey("img/second.jpg")
+ .fileOrder(2)
.originalFileName("second.jpg")
.fileSize(100L)
.fileType("image/jpeg")
.build();
- // ChatMessage의 chatMessageImgs에 역순으로 세팅
- ReflectionTestUtils.setField(message, "chatMessageImgs", List.of(img3, img1, img2));
+ // ChatMessage의 chatMessageFiles에 역순으로 세팅
+ ReflectionTestUtils.setField(message, "chatMessageFiles", List.of(img3, img1, img2));
- given(imageService.getUrlFromKey("img/first.jpg")).willReturn("https://cdn.example.com/first.jpg");
- given(imageService.getUrlFromKey("img/second.jpg")).willReturn("https://cdn.example.com/second.jpg");
- given(imageService.getUrlFromKey("img/third.jpg")).willReturn("https://cdn.example.com/third.jpg");
+ given(fileService.getUrlFromKey("img/first.jpg")).willReturn("https://cdn.example.com/first.jpg");
+ given(fileService.getUrlFromKey("img/second.jpg")).willReturn("https://cdn.example.com/second.jpg");
+ given(fileService.getUrlFromKey("img/third.jpg")).willReturn("https://cdn.example.com/third.jpg");
List result = chatProcessor.processMessages(sender.getId(), List.of(message));
- List images = result.get(0).images();
+ List images = result.get(0).images();
assertThat(images).hasSize(3);
assertThat(images.get(0).imgOrder()).isEqualTo(1);
assertThat(images.get(1).imgOrder()).isEqualTo(2);
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 f5455789e..47001068c 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
@@ -10,11 +10,13 @@
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.ChatMessageFile;
import umc.cockple.demo.domain.chat.domain.ChatRoom;
import umc.cockple.demo.domain.chat.domain.ChatRoomMember;
import umc.cockple.demo.domain.chat.dto.ChatMessageDTO;
import umc.cockple.demo.domain.chat.dto.ChatRoomDetailDTO;
import umc.cockple.demo.domain.chat.enums.ChatRoomType;
+import umc.cockple.demo.domain.chat.enums.MessageType;
import umc.cockple.demo.domain.chat.exception.ChatErrorCode;
import umc.cockple.demo.domain.chat.exception.ChatException;
import umc.cockple.demo.domain.chat.repository.ChatMessageRepository;
@@ -22,7 +24,7 @@
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.image.service.ImageService;
+import umc.cockple.demo.domain.file.service.FileService;
import umc.cockple.demo.domain.member.domain.Member;
import umc.cockple.demo.domain.member.repository.MemberPartyRepository;
import umc.cockple.demo.domain.party.domain.Party;
@@ -54,7 +56,7 @@ class ChatQueryServiceTest {
@Mock private MemberPartyRepository memberPartyRepository;
@Mock private MessageReadStatusRepository messageReadStatusRepository;
@Mock private ChatRoomListCacheService chatRoomListCacheService;
- @Mock private ImageService imageService;
+ @Mock private FileService fileService;
private ChatConverter chatConverter;
private ChatProcessor chatProcessor;
@@ -63,7 +65,7 @@ class ChatQueryServiceTest {
@BeforeEach
void setUp() {
chatConverter = new ChatConverter();
- chatProcessor = new ChatProcessor(imageService, chatConverter);
+ chatProcessor = new ChatProcessor(fileService, chatConverter);
chatQueryService = new ChatQueryServiceImpl(
chatRoomRepository,
chatRoomMemberRepository,
@@ -72,7 +74,7 @@ void setUp() {
memberPartyRepository,
messageReadStatusRepository,
chatConverter,
- imageService,
+ fileService,
chatProcessor,
chatRoomListCacheService
);
@@ -105,7 +107,7 @@ void partyChatRoom_success() {
given(chatRoomRepository.findChatRoomWithPartyById(roomId)).willReturn(Optional.of(chatRoom));
given(chatRoomMemberRepository.findByChatRoomIdAndMemberId(roomId, memberId)).willReturn(Optional.of(myMembership));
- given(chatMessageRepository.findRecentMessagesWithImages(eq(roomId), any())).willReturn(List.of());
+ given(chatMessageRepository.findRecentMessagesWithFiles(eq(roomId), any())).willReturn(List.of());
given(chatRoomMemberRepository.findChatRoomMembersWithMemberById(roomId)).willReturn(participants);
given(chatRoomMemberRepository.countByChatRoomId(roomId)).willReturn(1);
@@ -154,7 +156,7 @@ void directChatRoom_success() {
given(chatRoomMemberRepository.findByChatRoomIdAndMemberId(roomId, memberId)).willReturn(Optional.of(myMembership));
given(chatRoomMemberRepository.findCounterPartWithMember(roomId, memberId)).willReturn(Optional.of(counterPartMembership));
given(chatRoomMemberRepository.countByChatRoomId(roomId)).willReturn(2);
- given(chatMessageRepository.findRecentMessagesWithImages(eq(roomId), any())).willReturn(List.of());
+ given(chatMessageRepository.findRecentMessagesWithFiles(eq(roomId), any())).willReturn(List.of());
given(chatRoomMemberRepository.findChatRoomMembersWithMemberById(roomId)).willReturn(participants);
// when
@@ -197,7 +199,7 @@ void directChatRoom_counterPartWithdrawn() {
given(chatRoomMemberRepository.findByChatRoomIdAndMemberId(roomId, memberId)).willReturn(Optional.of(myMembership));
given(chatRoomMemberRepository.findCounterPartWithMember(roomId, memberId)).willReturn(Optional.of(withdrawnMembership));
given(chatRoomMemberRepository.countByChatRoomId(roomId)).willReturn(2);
- given(chatMessageRepository.findRecentMessagesWithImages(eq(roomId), any())).willReturn(List.of());
+ given(chatMessageRepository.findRecentMessagesWithFiles(eq(roomId), any())).willReturn(List.of());
given(chatRoomMemberRepository.findChatRoomMembersWithMemberById(roomId)).willReturn(participants);
// when
@@ -236,7 +238,7 @@ void messages_areReversedToChronologicalOrder() {
given(chatRoomRepository.findChatRoomWithPartyById(roomId)).willReturn(Optional.of(chatRoom));
given(chatRoomMemberRepository.findByChatRoomIdAndMemberId(roomId, memberId)).willReturn(Optional.of(myMembership));
- given(chatMessageRepository.findRecentMessagesWithImages(eq(roomId), any())).willReturn(List.of(msg3, msg2, msg1));
+ given(chatMessageRepository.findRecentMessagesWithFiles(eq(roomId), any())).willReturn(List.of(msg3, msg2, msg1));
given(chatRoomMemberRepository.findChatRoomMembersWithMemberById(roomId)).willReturn(List.of(myMembership));
given(chatRoomMemberRepository.countByChatRoomId(roomId)).willReturn(1);
@@ -275,7 +277,7 @@ void message_isMyMessage_true_whenSenderIsMe() {
given(chatRoomRepository.findChatRoomWithPartyById(roomId)).willReturn(Optional.of(chatRoom));
given(chatRoomMemberRepository.findByChatRoomIdAndMemberId(roomId, memberId)).willReturn(Optional.of(myMembership));
- given(chatMessageRepository.findRecentMessagesWithImages(eq(roomId), any())).willReturn(List.of(myMessage));
+ given(chatMessageRepository.findRecentMessagesWithFiles(eq(roomId), any())).willReturn(List.of(myMessage));
given(chatRoomMemberRepository.findChatRoomMembersWithMemberById(roomId)).willReturn(List.of(myMembership));
given(chatRoomMemberRepository.countByChatRoomId(roomId)).willReturn(1);
@@ -286,6 +288,7 @@ void message_isMyMessage_true_whenSenderIsMe() {
assertThat(result.messages().get(0).isMyMessage()).isTrue();
assertThat(result.messages().get(0).content()).isEqualTo("내 메시지");
assertThat(result.messages().get(0).senderName()).isEqualTo("홍길동");
+ assertThat(result.messages().get(0).images()).isEmpty();
}
@Test
@@ -319,7 +322,7 @@ void message_isMyMessage_false_whenSenderIsOther() {
given(chatRoomRepository.findChatRoomWithPartyById(roomId)).willReturn(Optional.of(chatRoom));
given(chatRoomMemberRepository.findByChatRoomIdAndMemberId(roomId, memberId)).willReturn(Optional.of(myMembership));
- given(chatMessageRepository.findRecentMessagesWithImages(eq(roomId), any())).willReturn(List.of(otherMessage));
+ given(chatMessageRepository.findRecentMessagesWithFiles(eq(roomId), any())).willReturn(List.of(otherMessage));
given(chatRoomMemberRepository.findChatRoomMembersWithMemberById(roomId)).willReturn(List.of(myMembership, otherMembership));
given(chatRoomMemberRepository.countByChatRoomId(roomId)).willReturn(2);
@@ -362,7 +365,7 @@ void message_isSenderWithdrawn_true() {
given(chatRoomRepository.findChatRoomWithPartyById(roomId)).willReturn(Optional.of(chatRoom));
given(chatRoomMemberRepository.findByChatRoomIdAndMemberId(roomId, memberId)).willReturn(Optional.of(myMembership));
- given(chatMessageRepository.findRecentMessagesWithImages(eq(roomId), any())).willReturn(List.of(withdrawnMessage));
+ given(chatMessageRepository.findRecentMessagesWithFiles(eq(roomId), any())).willReturn(List.of(withdrawnMessage));
given(chatRoomMemberRepository.findChatRoomMembersWithMemberById(roomId)).willReturn(List.of(myMembership, withdrawnMembership));
given(chatRoomMemberRepository.countByChatRoomId(roomId)).willReturn(2);
@@ -373,6 +376,57 @@ void message_isSenderWithdrawn_true() {
assertThat(result.messages().get(0).isSenderWithdrawn()).isTrue();
}
+ @Test
+ @DisplayName("이미지 메시지 조회 시 images 필드에 파일 정보가 포함된다")
+ void imageMessage_containsFileInfo() {
+ // given
+ Long roomId = 1L;
+ Long memberId = 10L;
+
+ Member me = MemberFixture.createMemberWithName("홍길동", "길동", Gender.MALE, Level.A, 1001L);
+ ReflectionTestUtils.setField(me, "id", memberId);
+
+ Party party = PartyFixture.createParty("모임", memberId, PartyFixture.createPartyAddr("서울", "강남구"));
+ ReflectionTestUtils.setField(party, "id", 100L);
+
+ ChatRoom chatRoom = ChatFixture.createPartyChatRoom(party);
+ ReflectionTestUtils.setField(chatRoom, "id", roomId);
+
+ ChatRoomMember myMembership = ChatFixture.createJoinedMember(chatRoom, me);
+ ReflectionTestUtils.setField(myMembership, "id", 1L);
+
+ ChatMessage imageMessage = ChatFixture.createImageMessage(chatRoom, me, List.of());
+ ReflectionTestUtils.setField(imageMessage, "id", 1L);
+
+ ChatMessageFile file1 = ChatFixture.createChatMessageFile(imageMessage, "chat/img1.png", 1, "photo1.png");
+ ReflectionTestUtils.setField(file1, "id", 100L);
+ ChatMessageFile file2 = ChatFixture.createChatMessageFile(imageMessage, "chat/img2.png", 2, "photo2.png");
+ ReflectionTestUtils.setField(file2, "id", 101L);
+ imageMessage.getChatMessageFiles().addAll(List.of(file1, file2));
+
+ given(chatRoomRepository.findChatRoomWithPartyById(roomId)).willReturn(Optional.of(chatRoom));
+ given(chatRoomMemberRepository.findByChatRoomIdAndMemberId(roomId, memberId)).willReturn(Optional.of(myMembership));
+ given(chatMessageRepository.findRecentMessagesWithFiles(eq(roomId), any())).willReturn(List.of(imageMessage));
+ given(chatRoomMemberRepository.findChatRoomMembersWithMemberById(roomId)).willReturn(List.of(myMembership));
+ given(chatRoomMemberRepository.countByChatRoomId(roomId)).willReturn(1);
+ given(fileService.getUrlFromKey("chat/img1.png")).willReturn("https://storage.example.com/chat/img1.png");
+ given(fileService.getUrlFromKey("chat/img2.png")).willReturn("https://storage.example.com/chat/img2.png");
+
+ // when
+ ChatRoomDetailDTO.Response result = chatQueryService.getChatRoomDetail(roomId, memberId);
+
+ // then
+ ChatRoomDetailDTO.MessageInfo message = result.messages().get(0);
+ assertThat(message.messageType()).isEqualTo(MessageType.TEXT);
+ assertThat(message.images()).hasSize(2);
+ assertThat(message.images().get(0).imageUrl()).isEqualTo("https://storage.example.com/chat/img1.png");
+ assertThat(message.images().get(0).imgOrder()).isEqualTo(1);
+ assertThat(message.images().get(0).originalFileName()).isEqualTo("photo1.png");
+ assertThat(message.images().get(0).isEmoji()).isFalse();
+ assertThat(message.images().get(1).imageUrl()).isEqualTo("https://storage.example.com/chat/img2.png");
+ assertThat(message.images().get(1).imgOrder()).isEqualTo(2);
+ }
+
@Test
@DisplayName("존재하지 않는 채팅방 조회 시 ChatException(CHAT_ROOM_NOT_FOUND)을 던진다")
void fail_chatRoomNotFound() {
@@ -563,6 +617,7 @@ void myMessage_isMyMessageTrue() {
// then
assertThat(result.messages().get(0).isMyMessage()).isTrue();
+ assertThat(result.messages().get(0).images()).isEmpty();
}
@Test
@@ -636,6 +691,53 @@ void withdrawnSenderMessage_isSenderWithdrawnTrue() {
assertThat(result.messages().get(0).isSenderWithdrawn()).isTrue();
}
+ @Test
+ @DisplayName("이미지 메시지 조회 시 images 필드에 파일 정보가 포함된다")
+ void imageMessage_containsFileInfo() {
+ // given
+ Long roomId = 1L;
+ Long memberId = 10L;
+ Long cursor = 100L;
+
+ Member me = MemberFixture.createMemberWithName("홍길동", "길동", Gender.MALE, Level.A, 1001L);
+ ReflectionTestUtils.setField(me, "id", memberId);
+
+ Party party = PartyFixture.createParty("모임", memberId, PartyFixture.createPartyAddr("서울", "강남구"));
+ ReflectionTestUtils.setField(party, "id", 100L);
+
+ ChatRoom chatRoom = ChatFixture.createPartyChatRoom(party);
+ ReflectionTestUtils.setField(chatRoom, "id", roomId);
+
+ ChatMessage imageMessage = ChatFixture.createImageMessage(chatRoom, me, List.of());
+ ReflectionTestUtils.setField(imageMessage, "id", 1L);
+
+ ChatMessageFile file1 = ChatFixture.createChatMessageFile(imageMessage, "chat/img1.png", 1, "photo1.png");
+ ReflectionTestUtils.setField(file1, "id", 100L);
+ ChatMessageFile file2 = ChatFixture.createChatMessageFile(imageMessage, "chat/img2.png", 2, "photo2.png");
+ ReflectionTestUtils.setField(file2, "id", 101L);
+ imageMessage.getChatMessageFiles().addAll(List.of(file1, file2));
+
+ given(chatRoomMemberRepository.existsByChatRoomIdAndMemberId(roomId, memberId)).willReturn(true);
+ given(chatMessageRepository.findByRoomIdAndIdLessThanOrderByCreatedAtDesc(eq(roomId), eq(cursor), any()))
+ .willReturn(List.of(imageMessage));
+ given(fileService.getUrlFromKey("chat/img1.png")).willReturn("https://storage.example.com/chat/img1.png");
+ given(fileService.getUrlFromKey("chat/img2.png")).willReturn("https://storage.example.com/chat/img2.png");
+
+ // when
+ ChatMessageDTO.Response result = chatQueryService.getChatMessages(roomId, memberId, cursor, 10);
+
+ // then
+ ChatMessageDTO.MessageInfo message = result.messages().get(0);
+ assertThat(message.messageType()).isEqualTo(MessageType.TEXT);
+ assertThat(message.images()).hasSize(2);
+ assertThat(message.images().get(0).imageUrl()).isEqualTo("https://storage.example.com/chat/img1.png");
+ assertThat(message.images().get(0).imgOrder()).isEqualTo(1);
+ assertThat(message.images().get(0).originalFileName()).isEqualTo("photo1.png");
+ assertThat(message.images().get(0).isEmoji()).isFalse();
+ assertThat(message.images().get(1).imageUrl()).isEqualTo("https://storage.example.com/chat/img2.png");
+ assertThat(message.images().get(1).imgOrder()).isEqualTo(2);
+ }
+
@Test
@DisplayName("채팅방 멤버가 아닌 사용자가 메시지를 조회하면 ChatException(CHAT_ROOM_ACCESS_DENIED)을 던진다")
void fail_notChatRoomMember() {
diff --git a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseCommandIntegrationTest.java
similarity index 58%
rename from src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseIntegrationTest.java
rename to src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseCommandIntegrationTest.java
index b179a0f73..bcaef277e 100644
--- a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseIntegrationTest.java
+++ b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseCommandIntegrationTest.java
@@ -8,6 +8,7 @@
import umc.cockple.demo.domain.exercise.domain.Guest;
import umc.cockple.demo.domain.exercise.dto.ExerciseCancelDTO;
import umc.cockple.demo.domain.exercise.dto.ExerciseCreateDTO;
+import umc.cockple.demo.domain.exercise.dto.ExerciseGuestInviteDTO;
import umc.cockple.demo.domain.exercise.dto.ExerciseUpdateDTO;
import umc.cockple.demo.domain.exercise.exception.ExerciseErrorCode;
import umc.cockple.demo.domain.exercise.repository.ExerciseRepository;
@@ -34,6 +35,7 @@
import umc.cockple.demo.support.fixture.GuestFixture;
import java.time.LocalDate;
+import java.time.LocalTime;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
@@ -42,7 +44,7 @@
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
-class ExerciseIntegrationTest extends IntegrationTestBase {
+class ExerciseCommandIntegrationTest extends IntegrationTestBase {
@Autowired MockMvc mockMvc;
@Autowired ObjectMapper objectMapper;
@@ -61,16 +63,16 @@ class ExerciseIntegrationTest extends IntegrationTestBase {
@BeforeEach
void setUp() {
- manager = memberRepository.save(MemberFixture.createMember("모임장", Gender.MALE, Level.A, 1001L));
- subManager = memberRepository.save(MemberFixture.createMember("부모임장", Gender.FEMALE, Level.B, 1002L));
- normalMember = memberRepository.save(MemberFixture.createMember("일반멤버", Gender.MALE, Level.C, 1003L));
+ manager = memberRepository.save(MemberFixture.createMember("모임장", Gender.MALE, Level.A, 1001L, LocalDate.of(2000, 1, 1)));
+ subManager = memberRepository.save(MemberFixture.createMember("부모임장", Gender.FEMALE, Level.B, 1002L, LocalDate.of(2000, 1, 1)));
+ normalMember = memberRepository.save(MemberFixture.createMember("일반멤버", Gender.MALE, Level.C, 1003L, LocalDate.of(2000, 1, 1)));
PartyAddr addr = partyAddrRepository.save(PartyFixture.createPartyAddr("서울특별시", "강남구"));
party = partyRepository.save(PartyFixture.createParty("테스트 모임", manager.getId(), addr));
- memberPartyRepository.save(MemberFixture.createMemberParty(party, manager, Role.party_MANAGER));
- memberPartyRepository.save(MemberFixture.createMemberParty(party, subManager, Role.party_SUBMANAGER));
- memberPartyRepository.save(MemberFixture.createMemberParty(party, normalMember, Role.party_MEMBER));
+ memberPartyRepository.save(MemberFixture.createMemberParty(party, manager, Role.PARTY_MANAGER));
+ memberPartyRepository.save(MemberFixture.createMemberParty(party, subManager, Role.PARTY_SUBMANAGER));
+ memberPartyRepository.save(MemberFixture.createMemberParty(party, normalMember, Role.PARTY_MEMBER));
}
@AfterEach
@@ -473,6 +475,239 @@ void pastDate() throws Exception {
}
}
+
+ @Nested
+ @DisplayName("POST /api/exercises/{exerciseId}/participants - 운동 신청")
+ class JoinExercise {
+
+ private Exercise exercise;
+
+ @BeforeEach
+ void setUp() {
+ party.addLevel(Gender.MALE, Level.A);
+ party.addLevel(Gender.MALE, Level.B);
+ party.addLevel(Gender.MALE, Level.C);
+ party.addLevel(Gender.FEMALE, Level.A);
+ party.addLevel(Gender.FEMALE, Level.B);
+ party.addLevel(Gender.FEMALE, Level.C);
+ partyRepository.save(party);
+
+ exercise = exerciseRepository.save(
+ ExerciseFixture.createExercise(party, LocalDate.of(2099, 12, 31),
+ LocalTime.of(12, 0), true, false));
+ }
+
+ @Nested
+ @DisplayName("성공 케이스")
+ class Success {
+
+ @Test
+ @DisplayName("201 - 파티 멤버가 운동 신청하면 참여 정보를 반환한다")
+ void partyMember_joinExercise() throws Exception {
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+
+ mockMvc.perform(post("/api/exercises/{exerciseId}/participants", exercise.getId()))
+ .andExpect(status().isCreated())
+ .andExpect(jsonPath("$.data.participantId").isNumber())
+ .andExpect(jsonPath("$.data.joinedAt").isString())
+ .andExpect(jsonPath("$.data.currentParticipants").value(1));
+ }
+
+ @Test
+ @DisplayName("201 - 파티 외부 멤버가 outsideGuestAccept=true 운동 신청하면 성공한다")
+ void outsideMember_joinExercise() throws Exception {
+ Exercise outsideAcceptExercise = exerciseRepository.save(
+ ExerciseFixture.createExercise(party, LocalDate.of(2099, 12, 31),
+ LocalTime.of(12, 0), true, true));
+
+ Member outsideMember = memberRepository.save(
+ MemberFixture.createMember("외부멤버", Gender.FEMALE, Level.C, 2001L, LocalDate.of(2000, 1, 1)));
+
+ SecurityContextHelper.setAuthentication(outsideMember.getId(), outsideMember.getNickname());
+
+ mockMvc.perform(post("/api/exercises/{exerciseId}/participants", outsideAcceptExercise.getId()))
+ .andExpect(status().isCreated())
+ .andExpect(jsonPath("$.data.participantId").isNumber())
+ .andExpect(jsonPath("$.data.currentParticipants").value(1));
+ }
+ }
+
+ @Nested
+ @DisplayName("실패 케이스")
+ class Failure {
+
+ @Test
+ @DisplayName("404 - 존재하지 않는 운동이면 에러를 반환한다")
+ void exerciseNotFound() throws Exception {
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+
+ mockMvc.perform(post("/api/exercises/{exerciseId}/participants", 999L))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.code").value(ExerciseErrorCode.EXERCISE_NOT_FOUND.getCode()))
+ .andExpect(jsonPath("$.message").value(ExerciseErrorCode.EXERCISE_NOT_FOUND.getMessage()));
+ }
+
+ @Test
+ @DisplayName("404 - 존재하지 않는 멤버면 에러를 반환한다")
+ void memberNotFound() throws Exception {
+ SecurityContextHelper.setAuthentication(999L, "없는멤버");
+
+ mockMvc.perform(post("/api/exercises/{exerciseId}/participants", exercise.getId()))
+ .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 alreadyStarted() throws Exception {
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+
+ Exercise startedExercise = exerciseRepository.save(
+ ExerciseFixture.createExercise(party, LocalDate.of(2000, 1, 1),
+ LocalTime.of(12, 0), true, false));
+
+ mockMvc.perform(post("/api/exercises/{exerciseId}/participants", startedExercise.getId()))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(ExerciseErrorCode.EXERCISE_ALREADY_STARTED_PARTICIPATION.getCode()))
+ .andExpect(jsonPath("$.message").value(ExerciseErrorCode.EXERCISE_ALREADY_STARTED_PARTICIPATION.getMessage()));
+ }
+
+ @Test
+ @DisplayName("400 - 이미 참여 신청한 운동이면 에러를 반환한다")
+ void alreadyJoined() throws Exception {
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+
+ memberExerciseRepository.save(
+ MemberFixture.createMemberExercise(normalMember, exercise));
+
+ mockMvc.perform(post("/api/exercises/{exerciseId}/participants", exercise.getId()))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(ExerciseErrorCode.ALREADY_JOINED_EXERCISE.getCode()))
+ .andExpect(jsonPath("$.message").value(ExerciseErrorCode.ALREADY_JOINED_EXERCISE.getMessage()));
+ }
+
+ @Test
+ @DisplayName("403 - 파티 멤버가 아닌데 외부 참여 불가 운동이면 에러를 반환한다")
+ void notPartyMember_outsideNotAccepted() throws Exception {
+ Member outsideMember = memberRepository.save(
+ MemberFixture.createMember("외부인", Gender.MALE, Level.B, 3001L, LocalDate.of(2000, 1, 1)));
+
+ SecurityContextHelper.setAuthentication(outsideMember.getId(), outsideMember.getNickname());
+
+ mockMvc.perform(post("/api/exercises/{exerciseId}/participants", exercise.getId()))
+ .andExpect(status().isForbidden())
+ .andExpect(jsonPath("$.code").value(ExerciseErrorCode.NOT_PARTY_MEMBER.getCode()))
+ .andExpect(jsonPath("$.message").value(ExerciseErrorCode.NOT_PARTY_MEMBER.getMessage()));
+ }
+
+ @Test
+ @DisplayName("403 - 나이 조건 불일치면 에러를 반환한다")
+ void ageNotAllowed() throws Exception {
+ Member youngMember = memberRepository.save(
+ MemberFixture.createMember("어린회원", Gender.MALE, Level.B, 4001L, LocalDate.of(2010, 1, 1)));
+ memberPartyRepository.save(MemberFixture.createMemberParty(party, youngMember, Role.PARTY_MEMBER));
+
+ SecurityContextHelper.setAuthentication(youngMember.getId(), youngMember.getNickname());
+
+ mockMvc.perform(post("/api/exercises/{exerciseId}/participants", exercise.getId()))
+ .andExpect(status().isForbidden())
+ .andExpect(jsonPath("$.code").value(ExerciseErrorCode.MEMBER_AGE_NOT_ALLOWED.getCode()))
+ .andExpect(jsonPath("$.message").value(ExerciseErrorCode.MEMBER_AGE_NOT_ALLOWED.getMessage()));
+ }
+
+ }
+ }
+
+ @Nested
+ @DisplayName("DELETE /api/exercises/{exerciseId}/participants/my - 운동 참여 취소")
+ class CancelParticipation {
+
+ private Exercise exercise;
+
+ @BeforeEach
+ void setUp() {
+ exercise = exerciseRepository.save(
+ ExerciseFixture.createExercise(party, LocalDate.of(2099, 12, 31),
+ LocalTime.of(12, 0), true, false));
+ }
+
+ @Nested
+ @DisplayName("성공 케이스")
+ class Success {
+
+ @Test
+ @DisplayName("200 - 참여자가 본인 참여를 취소하면 memberName을 반환한다")
+ void cancelParticipation_success() throws Exception {
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+
+ memberExerciseRepository.save(
+ MemberFixture.createMemberExercise(normalMember, exercise));
+
+ mockMvc.perform(delete("/api/exercises/{exerciseId}/participants/my", exercise.getId()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.memberName").value(normalMember.getMemberName()))
+ .andExpect(jsonPath("$.data.currentParticipants").value(0));
+ }
+ }
+
+ @Nested
+ @DisplayName("실패 케이스")
+ class Failure {
+
+ @Test
+ @DisplayName("404 - 존재하지 않는 운동이면 에러를 반환한다")
+ void exerciseNotFound() throws Exception {
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+
+ mockMvc.perform(delete("/api/exercises/{exerciseId}/participants/my", 999L))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.code").value(ExerciseErrorCode.EXERCISE_NOT_FOUND.getCode()))
+ .andExpect(jsonPath("$.message").value(ExerciseErrorCode.EXERCISE_NOT_FOUND.getMessage()));
+ }
+
+ @Test
+ @DisplayName("404 - SecurityContext의 멤버가 DB에 없으면 에러를 반환한다")
+ void memberNotFound() throws Exception {
+ SecurityContextHelper.setAuthentication(999L, "없는멤버");
+
+ mockMvc.perform(delete("/api/exercises/{exerciseId}/participants/my", exercise.getId()))
+ .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 alreadyStarted() throws Exception {
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+
+ Exercise startedExercise = exerciseRepository.save(
+ ExerciseFixture.createExercise(party, LocalDate.of(2000, 1, 1),
+ LocalTime.of(12, 0), true, false));
+
+ memberExerciseRepository.save(
+ MemberFixture.createMemberExercise(normalMember, startedExercise));
+
+ mockMvc.perform(delete("/api/exercises/{exerciseId}/participants/my", startedExercise.getId()))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(ExerciseErrorCode.EXERCISE_ALREADY_STARTED_CANCEL.getCode()))
+ .andExpect(jsonPath("$.message").value(ExerciseErrorCode.EXERCISE_ALREADY_STARTED_CANCEL.getMessage()));
+ }
+
+ @Test
+ @DisplayName("404 - 참여 기록이 없으면 에러를 반환한다")
+ void memberExerciseNotFound() throws Exception {
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+
+ mockMvc.perform(delete("/api/exercises/{exerciseId}/participants/my", exercise.getId()))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.code").value(ExerciseErrorCode.MEMBER_EXERCISE_NOT_FOUND.getCode()))
+ .andExpect(jsonPath("$.message").value(ExerciseErrorCode.MEMBER_EXERCISE_NOT_FOUND.getMessage()));
+ }
+ }
+ }
+
@Nested
@DisplayName("DELETE /api/exercises/{exerciseId}/participants/{participantId} - 특정 참여자 운동 취소")
class CancelParticipationByManager {
@@ -620,15 +855,16 @@ void alreadyStarted() throws Exception {
}
@Nested
- @DisplayName("GET /api/exercises/{exerciseId} - 운동 상세 조회")
- class GetExerciseDetail {
+ @DisplayName("POST /api/exercises/{exerciseId}/guests - 게스트 초대")
+ class InviteGuest {
private Exercise exercise;
@BeforeEach
void setUp() {
exercise = exerciseRepository.save(
- ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().minusDays(1)));
+ ExerciseFixture.createExercise(party, LocalDate.of(2099, 12, 31),
+ LocalTime.of(12, 0), true, false));
}
@Nested
@@ -636,160 +872,149 @@ void setUp() {
class Success {
@Test
- @DisplayName("응답의 모든 주요 필드가 올바르게 반환된다")
- void 응답의_모든_주요_필드가_올바르게_반환된다() throws Exception {
- SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
-
- memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, exercise));
-
- mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId()))
- .andExpect(status().isOk())
- .andExpect(jsonPath("$.data.isManager").value(true))
- .andExpect(jsonPath("$.data.info.buildingName").value("테스트 체육관"))
- .andExpect(jsonPath("$.data.info.location").value("서울특별시 강남구 테헤란로 1"))
- .andExpect(jsonPath("$.data.participants.currentParticipantCount").value(1))
- .andExpect(jsonPath("$.data.participants.totalCount").value(10))
- .andExpect(jsonPath("$.data.participants.manCount").value(1))
- .andExpect(jsonPath("$.data.participants.womenCount").value(0))
- .andExpect(jsonPath("$.data.participants.list[0].participantNumber").value(1))
- .andExpect(jsonPath("$.data.participants.list[0].name").isString())
- .andExpect(jsonPath("$.data.participants.list[0].gender").value("MALE"))
- .andExpect(jsonPath("$.data.participants.list[0].level").isString())
- .andExpect(jsonPath("$.data.participants.list[0].participantType").isString())
- .andExpect(jsonPath("$.data.participants.list[0].isWithdrawn").value(false))
- .andExpect(jsonPath("$.data.waiting.currentWaitingCount").value(0));
- }
-
- @Test
- @DisplayName("활성 회원 참가자는 isWithdrawn false로 반환된다")
- void 활성_회원_참가자는_isWithdrawn_false로_반환된다() throws Exception {
- SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+ @DisplayName("201 - 파티 멤버가 게스트를 초대하면 guestId를 반환한다")
+ void partyMember_inviteGuest() throws Exception {
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
- memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, exercise));
+ ExerciseGuestInviteDTO.Request request = new ExerciseGuestInviteDTO.Request(
+ "테스트게스트", "남성", "B조");
- mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId()))
- .andExpect(status().isOk())
- .andExpect(jsonPath("$.data.participants.list[0].isWithdrawn").value(false));
+ mockMvc.perform(post("/api/exercises/{exerciseId}/guests", exercise.getId())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isCreated())
+ .andExpect(jsonPath("$.data.guestId").isNumber())
+ .andExpect(jsonPath("$.data.invitedAt").isString())
+ .andExpect(jsonPath("$.data.currentParticipants").value(1));
}
+ }
- @Test
- @DisplayName("탈퇴 회원 참가자는 isWithdrawn true로 반환된다")
- void 탈퇴_회원_참가자는_isWithdrawn_true로_반환된다() throws Exception {
- SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+ @Nested
+ @DisplayName("실패 케이스")
+ class Failure {
- Member withdrawnMember = memberRepository.save(
- MemberFixture.createWithdrawnMember("탈퇴회원", "탈퇴닉네임", 8888L));
+ @Test
+ @DisplayName("404 - 존재하지 않는 운동이면 에러를 반환한다")
+ void exerciseNotFound() throws Exception {
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
- memberExerciseRepository.save(MemberFixture.createMemberExercise(withdrawnMember, exercise));
+ ExerciseGuestInviteDTO.Request request = new ExerciseGuestInviteDTO.Request(
+ "테스트게스트", "남성", "B조");
- mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId()))
- .andExpect(status().isOk())
- .andExpect(jsonPath("$.data.participants.list[0].isWithdrawn").value(true));
+ mockMvc.perform(post("/api/exercises/{exerciseId}/guests", 999L)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.code").value(ExerciseErrorCode.EXERCISE_NOT_FOUND.getCode()))
+ .andExpect(jsonPath("$.message").value(ExerciseErrorCode.EXERCISE_NOT_FOUND.getMessage()));
}
@Test
- @DisplayName("모임장이 조회하면 isManager true로 반환된다")
- void 모임장이_조회하면_isManager_true로_반환된다() throws Exception {
- SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
-
- mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId()))
- .andExpect(status().isOk())
- .andExpect(jsonPath("$.data.isManager").value(true));
- }
+ @DisplayName("404 - SecurityContext의 멤버가 DB에 없으면 에러를 반환한다")
+ void memberNotFound() throws Exception {
+ SecurityContextHelper.setAuthentication(999L, "없는멤버");
- @Test
- @DisplayName("일반 멤버가 조회하면 isManager false로 반환된다")
- void 일반_멤버가_조회하면_isManager_false로_반환된다() throws Exception {
- SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+ ExerciseGuestInviteDTO.Request request = new ExerciseGuestInviteDTO.Request(
+ "테스트게스트", "남성", "B조");
- mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId()))
- .andExpect(status().isOk())
- .andExpect(jsonPath("$.data.isManager").value(false));
+ mockMvc.perform(post("/api/exercises/{exerciseId}/guests", exercise.getId())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.code").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getCode()))
+ .andExpect(jsonPath("$.message").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getMessage()));
}
@Test
- @DisplayName("정원 초과 참가자는 대기자로 반환된다")
- void 정원_초과_참가자는_대기자로_반환된다() throws Exception {
- SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+ @DisplayName("400 - 이미 시작된 운동이면 에러를 반환한다")
+ void alreadyStarted() throws Exception {
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
- Exercise smallExercise = exerciseRepository.save(
- ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().minusDays(1), 1));
+ Exercise startedExercise = exerciseRepository.save(
+ ExerciseFixture.createExercise(party, LocalDate.of(2000, 1, 1),
+ LocalTime.of(12, 0), true, false));
- memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, smallExercise));
- memberExerciseRepository.save(MemberFixture.createMemberExercise(subManager, smallExercise));
+ ExerciseGuestInviteDTO.Request request = new ExerciseGuestInviteDTO.Request(
+ "테스트게스트", "남성", "B조");
- mockMvc.perform(get("/api/exercises/{exerciseId}", smallExercise.getId()))
- .andExpect(status().isOk())
- .andExpect(jsonPath("$.data.participants.currentParticipantCount").value(1))
- .andExpect(jsonPath("$.data.waiting.currentWaitingCount").value(1));
+ mockMvc.perform(post("/api/exercises/{exerciseId}/guests", startedExercise.getId())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(ExerciseErrorCode.EXERCISE_ALREADY_STARTED_INVITATION.getCode()))
+ .andExpect(jsonPath("$.message").value(ExerciseErrorCode.EXERCISE_ALREADY_STARTED_INVITATION.getMessage()));
}
@Test
- @DisplayName("게스트 참가자는 inviterName이 반환된다")
- void 게스트_참가자는_inviterName이_반환된다() throws Exception {
- SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+ @DisplayName("403 - 파티 멤버가 아닌 사용자가 초대하면 에러를 반환한다")
+ void notPartyMember() throws Exception {
+ Member outsideMember = memberRepository.save(
+ MemberFixture.createMember("외부인", Gender.MALE, Level.B, 3001L, LocalDate.of(2000, 1, 1)));
- guestRepository.save(GuestFixture.createGuest(exercise, manager.getId()));
+ SecurityContextHelper.setAuthentication(outsideMember.getId(), outsideMember.getNickname());
- mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId()))
- .andExpect(status().isOk())
- .andExpect(jsonPath("$.data.participants.list[0].participantType").value("GUEST"))
- .andExpect(jsonPath("$.data.participants.list[0].inviterName").isString());
+ ExerciseGuestInviteDTO.Request request = new ExerciseGuestInviteDTO.Request(
+ "테스트게스트", "남성", "B조");
+
+ mockMvc.perform(post("/api/exercises/{exerciseId}/guests", exercise.getId())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isForbidden())
+ .andExpect(jsonPath("$.code").value(ExerciseErrorCode.NOT_PARTY_MEMBER_FOR_GUEST_INVITE.getCode()))
+ .andExpect(jsonPath("$.message").value(ExerciseErrorCode.NOT_PARTY_MEMBER_FOR_GUEST_INVITE.getMessage()));
}
@Test
- @DisplayName("먼저 가입한 참가자가 더 낮은 participantNumber를 받는다")
- void 먼저_가입한_참가자가_더_낮은_participantNumber를_받는다() throws Exception {
- SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+ @DisplayName("403 - 게스트 초대 정책 비허용이면 에러를 반환한다")
+ void guestPolicyNotAllowed() throws Exception {
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
- memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, exercise));
- memberExerciseRepository.save(MemberFixture.createMemberExercise(subManager, exercise));
+ Exercise noGuestExercise = exerciseRepository.save(
+ ExerciseFixture.createExercise(party, LocalDate.of(2099, 12, 31),
+ LocalTime.of(12, 0), false, false));
- mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId()))
- .andExpect(status().isOk())
- .andExpect(jsonPath("$.data.participants.currentParticipantCount").value(2))
- .andExpect(jsonPath("$.data.participants.list[0].participantNumber").value(1))
- .andExpect(jsonPath("$.data.participants.list[1].participantNumber").value(2))
- .andExpect(jsonPath("$.data.participants.list[0].name").value(normalMember.getMemberName()))
- .andExpect(jsonPath("$.data.participants.list[1].name").value(subManager.getMemberName()));
+ ExerciseGuestInviteDTO.Request request = new ExerciseGuestInviteDTO.Request(
+ "테스트게스트", "남성", "B조");
+
+ mockMvc.perform(post("/api/exercises/{exerciseId}/guests", noGuestExercise.getId())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isForbidden())
+ .andExpect(jsonPath("$.code").value(ExerciseErrorCode.GUEST_INVITATION_NOT_ALLOWED.getCode()))
+ .andExpect(jsonPath("$.message").value(ExerciseErrorCode.GUEST_INVITATION_NOT_ALLOWED.getMessage()));
}
+ }
+ }
- @Test
- @DisplayName("대기자의 성별 카운트가 올바르게 반환된다")
- void 대기자의_성별_카운트가_올바르게_반환된다() throws Exception {
- SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+ @Nested
+ @DisplayName("DELETE /api/exercises/{exerciseId}/guests/{guestId} - 게스트 초대 취소")
+ class CancelGuestInvitation {
- // 정원 1명짜리 운동: normalMember(MALE) 참가, subManager(FEMALE) 대기
- Exercise smallExercise = exerciseRepository.save(
- ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().minusDays(1), 1));
+ private Exercise exercise;
- memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, smallExercise));
- memberExerciseRepository.save(MemberFixture.createMemberExercise(subManager, smallExercise));
+ @BeforeEach
+ void setUp() {
+ exercise = exerciseRepository.save(
+ ExerciseFixture.createExercise(party, LocalDate.of(2099, 12, 31),
+ LocalTime.of(12, 0), true, false));
+ }
- mockMvc.perform(get("/api/exercises/{exerciseId}", smallExercise.getId()))
- .andExpect(status().isOk())
- .andExpect(jsonPath("$.data.participants.currentParticipantCount").value(1))
- .andExpect(jsonPath("$.data.participants.manCount").value(1))
- .andExpect(jsonPath("$.data.participants.womenCount").value(0))
- .andExpect(jsonPath("$.data.waiting.currentWaitingCount").value(1))
- .andExpect(jsonPath("$.data.waiting.manCount").value(0))
- .andExpect(jsonPath("$.data.waiting.womenCount").value(1));
- }
+ @Nested
+ @DisplayName("성공 케이스")
+ class Success {
@Test
- @DisplayName("남성과 여성 참가자가 있을 때 성별 카운트가 올바르게 반환된다")
- void 남성과_여성_참가자가_있을_때_성별_카운트가_올바르게_반환된다() throws Exception {
- SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+ @DisplayName("200 - 초대자가 본인 게스트를 취소하면 memberName을 반환한다")
+ void cancelGuestInvitation_success() throws Exception {
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
- // normalMember: MALE, subManager: FEMALE
- memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, exercise));
- memberExerciseRepository.save(MemberFixture.createMemberExercise(subManager, exercise));
+ Guest guest = guestRepository.save(GuestFixture.createGuest(exercise, normalMember.getId()));
- mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId()))
+ mockMvc.perform(delete("/api/exercises/{exerciseId}/guests/{guestId}",
+ exercise.getId(), guest.getId()))
.andExpect(status().isOk())
- .andExpect(jsonPath("$.data.participants.currentParticipantCount").value(2))
- .andExpect(jsonPath("$.data.participants.manCount").value(1))
- .andExpect(jsonPath("$.data.participants.womenCount").value(1));
+ .andExpect(jsonPath("$.data.memberName").value("게스트"))
+ .andExpect(jsonPath("$.data.currentParticipants").value(0));
}
}
@@ -798,26 +1023,77 @@ class Success {
class Failure {
@Test
- @DisplayName("존재하지 않는 운동이면 에러를 반환한다")
- void 존재하지_않는_운동이면_에러를_반환한다() throws Exception {
- SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+ @DisplayName("404 - 존재하지 않는 운동이면 에러를 반환한다")
+ void exerciseNotFound() throws Exception {
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+
+ Guest guest = guestRepository.save(GuestFixture.createGuest(exercise, normalMember.getId()));
- mockMvc.perform(get("/api/exercises/{exerciseId}", 999L))
+ mockMvc.perform(delete("/api/exercises/{exerciseId}/guests/{guestId}",
+ 999L, guest.getId()))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(ExerciseErrorCode.EXERCISE_NOT_FOUND.getCode()))
.andExpect(jsonPath("$.message").value(ExerciseErrorCode.EXERCISE_NOT_FOUND.getMessage()));
}
@Test
- @DisplayName("존재하지 않는 멤버면 에러를 반환한다")
- void 존재하지_않는_멤버면_에러를_반환한다() throws Exception {
+ @DisplayName("404 - SecurityContext의 멤버가 DB에 없으면 에러를 반환한다")
+ void memberNotFound() throws Exception {
SecurityContextHelper.setAuthentication(999L, "없는멤버");
- mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId()))
+ Guest guest = guestRepository.save(GuestFixture.createGuest(exercise, normalMember.getId()));
+
+ mockMvc.perform(delete("/api/exercises/{exerciseId}/guests/{guestId}",
+ exercise.getId(), guest.getId()))
.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 alreadyStarted() throws Exception {
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+
+ Exercise startedExercise = exerciseRepository.save(
+ ExerciseFixture.createExercise(party, LocalDate.of(2000, 1, 1),
+ LocalTime.of(12, 0), true, false));
+
+ Guest guest = guestRepository.save(GuestFixture.createGuest(startedExercise, normalMember.getId()));
+
+ mockMvc.perform(delete("/api/exercises/{exerciseId}/guests/{guestId}",
+ startedExercise.getId(), guest.getId()))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(ExerciseErrorCode.EXERCISE_ALREADY_STARTED_CANCEL.getCode()))
+ .andExpect(jsonPath("$.message").value(ExerciseErrorCode.EXERCISE_ALREADY_STARTED_CANCEL.getMessage()));
+ }
+
+ @Test
+ @DisplayName("404 - 존재하지 않는 게스트면 에러를 반환한다")
+ void guestNotFound() throws Exception {
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+
+ mockMvc.perform(delete("/api/exercises/{exerciseId}/guests/{guestId}",
+ exercise.getId(), 999L))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.code").value(ExerciseErrorCode.GUEST_NOT_FOUND.getCode()))
+ .andExpect(jsonPath("$.message").value(ExerciseErrorCode.GUEST_NOT_FOUND.getMessage()));
+ }
+
+ @Test
+ @DisplayName("403 - 본인이 초대하지 않은 게스트면 에러를 반환한다")
+ void guestNotInvitedByMember() throws Exception {
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+
+ Guest guest = guestRepository.save(GuestFixture.createGuest(exercise, manager.getId()));
+
+ mockMvc.perform(delete("/api/exercises/{exerciseId}/guests/{guestId}",
+ exercise.getId(), guest.getId()))
+ .andExpect(status().isForbidden())
+ .andExpect(jsonPath("$.code").value(ExerciseErrorCode.GUEST_NOT_INVITED_BY_MEMBER.getCode()))
+ .andExpect(jsonPath("$.message").value(ExerciseErrorCode.GUEST_NOT_INVITED_BY_MEMBER.getMessage()));
+ }
}
}
+
}
diff --git a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java
new file mode 100644
index 000000000..de9e789f5
--- /dev/null
+++ b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java
@@ -0,0 +1,1697 @@
+package umc.cockple.demo.domain.exercise.integration;
+
+import org.junit.jupiter.api.*;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.test.web.servlet.MockMvc;
+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.domain.MemberAddr;
+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.party.domain.Party;
+import umc.cockple.demo.domain.party.domain.PartyAddr;
+import umc.cockple.demo.domain.party.enums.ActivityTime;
+import umc.cockple.demo.domain.party.enums.ParticipationType;
+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.ExerciseCalendarTestHelper;
+import umc.cockple.demo.support.IntegrationTestBase;
+import umc.cockple.demo.support.SecurityContextHelper;
+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.LocalTime;
+import java.util.ArrayList;
+import java.util.List;
+
+import javax.sql.DataSource;
+
+import static org.hamcrest.Matchers.containsInAnyOrder;
+import static org.hamcrest.Matchers.nullValue;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+class ExerciseQueryIntegrationTest extends IntegrationTestBase {
+
+ @Autowired MockMvc mockMvc;
+ @Autowired MemberRepository memberRepository;
+ @Autowired MemberAddrRepository memberAddrRepository;
+ @Autowired PartyRepository partyRepository;
+ @Autowired PartyAddrRepository partyAddrRepository;
+ @Autowired MemberPartyRepository memberPartyRepository;
+ @Autowired ExerciseRepository exerciseRepository;
+ @Autowired MemberExerciseRepository memberExerciseRepository;
+ @Autowired GuestRepository guestRepository;
+ @Autowired ExerciseBookmarkRepository exerciseBookmarkRepository;
+ @Autowired DataSource dataSource;
+
+ private Member manager;
+ private Member subManager;
+ private Member normalMember;
+ private Member outsider;
+ private Party party;
+
+ @BeforeEach
+ void setUp() {
+ manager = memberRepository.save(MemberFixture.createMember("모임장", Gender.MALE, Level.A, 1001L, LocalDate.of(2000, 1, 1)));
+ subManager = memberRepository.save(MemberFixture.createMember("부모임장", Gender.FEMALE, Level.B, 1002L, LocalDate.of(2000, 1, 1)));
+ normalMember = memberRepository.save(MemberFixture.createMember("일반멤버", Gender.MALE, Level.C, 1003L, LocalDate.of(2000, 1, 1)));
+ outsider = memberRepository.save(MemberFixture.createMember("외부회원", Gender.FEMALE, Level.B, 1004L, LocalDate.of(2001, 1, 1)));
+
+ PartyAddr addr = partyAddrRepository.save(PartyFixture.createPartyAddr("서울특별시", "강남구"));
+ party = partyRepository.save(PartyFixture.createParty("테스트 모임", manager.getId(), addr));
+
+ memberPartyRepository.save(MemberFixture.createMemberParty(party, manager, Role.PARTY_MANAGER));
+ memberPartyRepository.save(MemberFixture.createMemberParty(party, subManager, Role.PARTY_SUBMANAGER));
+ memberPartyRepository.save(MemberFixture.createMemberParty(party, normalMember, Role.PARTY_MEMBER));
+ }
+
+ @AfterEach
+ void tearDown() {
+ guestRepository.deleteAll();
+ exerciseBookmarkRepository.deleteAll();
+ memberExerciseRepository.deleteAll();
+ exerciseRepository.deleteAll();
+ memberPartyRepository.deleteAll();
+ partyRepository.deleteAll();
+ partyAddrRepository.deleteAll();
+ memberAddrRepository.deleteAll();
+ memberRepository.deleteAll();
+ SecurityContextHelper.clearAuthentication();
+ }
+
+ @Nested
+ @DisplayName("GET /api/exercises/{exerciseId} - 운동 상세 조회")
+ class GetExerciseDetail {
+
+ private Exercise exercise;
+
+ @BeforeEach
+ void setUp() {
+ exercise = exerciseRepository.save(
+ ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().minusDays(1)));
+ }
+
+ @Nested
+ @DisplayName("성공 케이스")
+ class Success {
+
+ @Test
+ @DisplayName("응답의 모든 주요 필드가 올바르게 반환된다")
+ void 응답의_모든_주요_필드가_올바르게_반환된다() throws Exception {
+ SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+
+ Exercise smallExercise = exerciseRepository.save(
+ ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().minusDays(1), 1));
+
+ memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, smallExercise));
+ memberExerciseRepository.save(MemberFixture.createMemberExercise(subManager, smallExercise));
+
+ mockMvc.perform(get("/api/exercises/{exerciseId}", smallExercise.getId()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.isManager").value(true))
+ .andExpect(jsonPath("$.data.info.buildingName").value("테스트 체육관"))
+ .andExpect(jsonPath("$.data.info.location").value("서울특별시 강남구 테헤란로 1"))
+ .andExpect(jsonPath("$.data.participants.currentParticipantCount").value(1))
+ .andExpect(jsonPath("$.data.participants.totalCount").value(1))
+ .andExpect(jsonPath("$.data.participants.manCount").value(1))
+ .andExpect(jsonPath("$.data.participants.womenCount").value(0))
+ .andExpect(jsonPath("$.data.participants.list[0].participantNumber").value(1))
+ .andExpect(jsonPath("$.data.participants.list[0].name").value(normalMember.getMemberName()))
+ .andExpect(jsonPath("$.data.participants.list[0].gender").value("MALE"))
+ .andExpect(jsonPath("$.data.participants.list[0].level").isString())
+ .andExpect(jsonPath("$.data.participants.list[0].participantType").value("PARTY_MEMBER"))
+ .andExpect(jsonPath("$.data.participants.list[0].partyPosition").value("PARTY_MEMBER"))
+ .andExpect(jsonPath("$.data.participants.list[0].isWithdrawn").value(false))
+ .andExpect(jsonPath("$.data.waiting.currentWaitingCount").value(1))
+ .andExpect(jsonPath("$.data.waiting.manCount").value(0))
+ .andExpect(jsonPath("$.data.waiting.womenCount").value(1))
+ .andExpect(jsonPath("$.data.waiting.list[0].name").value(subManager.getMemberName()))
+ .andExpect(jsonPath("$.data.waiting.list[0].gender").value("FEMALE"))
+ .andExpect(jsonPath("$.data.waiting.list[0].participantType").value("PARTY_MEMBER"))
+ .andExpect(jsonPath("$.data.waiting.list[0].partyPosition").value("PARTY_SUBMANAGER"))
+ .andExpect(jsonPath("$.data.waiting.list[0].isWithdrawn").value(false));
+ }
+
+ @Test
+ @DisplayName("모임장이 조회하면 isManager true로 반환된다")
+ void 모임장이_조회하면_isManager_true로_반환된다() throws Exception {
+ SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+
+ mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.isManager").value(true));
+ }
+
+ @Test
+ @DisplayName("일반 멤버가 조회하면 isManager false로 반환된다")
+ void 일반_멤버가_조회하면_isManager_false로_반환된다() throws Exception {
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+
+ mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.isManager").value(false));
+ }
+
+ @Test
+ @DisplayName("부모임장이 조회해도 isManager false로 반환된다")
+ void 부모임장이_조회해도_isManager_false로_반환된다() throws Exception {
+ SecurityContextHelper.setAuthentication(subManager.getId(), subManager.getNickname());
+
+ mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.isManager").value(false));
+ }
+
+ @Test
+ @DisplayName("모임 외부 회원이 조회해도 isManager false로 반환된다")
+ void 모임_외부_회원이_조회해도_isManager_false로_반환된다() throws Exception {
+ SecurityContextHelper.setAuthentication(outsider.getId(), outsider.getNickname());
+
+ mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.isManager").value(false))
+ .andExpect(jsonPath("$.data.info.buildingName").value("테스트 체육관"));
+ }
+
+ @Test
+ @DisplayName("정원 초과 참가자는 대기자로 반환된다")
+ void 정원_초과_참가자는_대기자로_반환된다() throws Exception {
+ SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+
+ Exercise smallExercise = exerciseRepository.save(
+ ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().minusDays(1), 1));
+
+ memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, smallExercise));
+ memberExerciseRepository.save(MemberFixture.createMemberExercise(subManager, smallExercise));
+
+ mockMvc.perform(get("/api/exercises/{exerciseId}", smallExercise.getId()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.participants.currentParticipantCount").value(1))
+ .andExpect(jsonPath("$.data.waiting.currentWaitingCount").value(1));
+ }
+
+ @Test
+ @DisplayName("먼저 가입한 참가자가 더 낮은 participantNumber를 받는다")
+ void 먼저_가입한_참가자가_더_낮은_participantNumber를_받는다() throws Exception {
+ SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+
+ memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, exercise));
+ memberExerciseRepository.save(MemberFixture.createMemberExercise(subManager, exercise));
+
+ mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.participants.currentParticipantCount").value(2))
+ .andExpect(jsonPath("$.data.participants.list[0].participantNumber").value(1))
+ .andExpect(jsonPath("$.data.participants.list[1].participantNumber").value(2))
+ .andExpect(jsonPath("$.data.participants.list[0].name").value(normalMember.getMemberName()))
+ .andExpect(jsonPath("$.data.participants.list[1].name").value(subManager.getMemberName()));
+ }
+
+ @Test
+ @DisplayName("참가자의 성별 카운트가 올바르게 반환된다")
+ void 참가자의_성별_카운트가_올바르게_반환된다() throws Exception {
+ SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+
+ memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, exercise));
+ memberExerciseRepository.save(MemberFixture.createMemberExercise(subManager, exercise));
+
+ mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.participants.currentParticipantCount").value(2))
+ .andExpect(jsonPath("$.data.participants.manCount").value(1))
+ .andExpect(jsonPath("$.data.participants.womenCount").value(1));
+ }
+
+ @Test
+ @DisplayName("대기자의 성별 카운트가 올바르게 반환된다")
+ void 대기자의_성별_카운트가_올바르게_반환된다() throws Exception {
+ SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+
+ Exercise smallExercise = exerciseRepository.save(
+ ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().minusDays(1), 1));
+
+ memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, smallExercise));
+ memberExerciseRepository.save(MemberFixture.createMemberExercise(subManager, smallExercise));
+
+ mockMvc.perform(get("/api/exercises/{exerciseId}", smallExercise.getId()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.participants.currentParticipantCount").value(1))
+ .andExpect(jsonPath("$.data.participants.manCount").value(1))
+ .andExpect(jsonPath("$.data.participants.womenCount").value(0))
+ .andExpect(jsonPath("$.data.waiting.currentWaitingCount").value(1))
+ .andExpect(jsonPath("$.data.waiting.manCount").value(0))
+ .andExpect(jsonPath("$.data.waiting.womenCount").value(1));
+ }
+
+ @Test
+ @DisplayName("참가자 유형별 partyPosition이 올바르게 반환된다")
+ void 참가자_유형별_partyPosition이_올바르게_반환된다() throws Exception {
+ SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+
+ memberExerciseRepository.save(MemberFixture.createMemberExercise(manager, exercise));
+ memberExerciseRepository.save(MemberFixture.createMemberExercise(subManager, exercise));
+ memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, exercise));
+ memberExerciseRepository.save(MemberFixture.createExternalMemberExercise(outsider, exercise));
+ guestRepository.save(GuestFixture.createGuest(exercise, manager.getId()));
+
+ mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.participants.list[0].name").value(manager.getMemberName()))
+ .andExpect(jsonPath("$.data.participants.list[0].participantType").value("PARTY_MEMBER"))
+ .andExpect(jsonPath("$.data.participants.list[0].partyPosition").value("PARTY_MANAGER"))
+ .andExpect(jsonPath("$.data.participants.list[1].name").value(subManager.getMemberName()))
+ .andExpect(jsonPath("$.data.participants.list[1].participantType").value("PARTY_MEMBER"))
+ .andExpect(jsonPath("$.data.participants.list[1].partyPosition").value("PARTY_SUBMANAGER"))
+ .andExpect(jsonPath("$.data.participants.list[2].name").value(normalMember.getMemberName()))
+ .andExpect(jsonPath("$.data.participants.list[2].participantType").value("PARTY_MEMBER"))
+ .andExpect(jsonPath("$.data.participants.list[2].partyPosition").value("PARTY_MEMBER"))
+ .andExpect(jsonPath("$.data.participants.list[3].name").value(outsider.getMemberName()))
+ .andExpect(jsonPath("$.data.participants.list[3].participantType").value("EXTERNAL_PARTICIPANT"))
+ .andExpect(jsonPath("$.data.participants.list[3].partyPosition").value(nullValue()))
+ .andExpect(jsonPath("$.data.participants.list[4].name").value("게스트"))
+ .andExpect(jsonPath("$.data.participants.list[4].participantType").value("GUEST"))
+ .andExpect(jsonPath("$.data.participants.list[4].partyPosition").value(nullValue()));
+ }
+
+ @Test
+ @DisplayName("게스트 참가자의 inviterName이 반환된다")
+ void 게스트_참가자의_inviterName이_반환된다() throws Exception {
+ SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+
+ guestRepository.save(GuestFixture.createGuest(exercise, manager.getId()));
+
+ mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.participants.list[0].participantType").value("GUEST"))
+ .andExpect(jsonPath("$.data.participants.list[0].inviterName").value(manager.getMemberName()));
+ }
+
+ @Test
+ @DisplayName("활성 회원 참가자는 isWithdrawn false로 반환된다")
+ void 활성_회원_참가자는_isWithdrawn_false로_반환된다() throws Exception {
+ SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+
+ memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, exercise));
+
+ mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.participants.list[0].isWithdrawn").value(false));
+ }
+
+ @Test
+ @DisplayName("탈퇴 회원 참가자는 isWithdrawn true로 반환된다")
+ void 탈퇴_회원_참가자는_isWithdrawn_true로_반환된다() throws Exception {
+ SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+
+ Member withdrawnMember = memberRepository.save(
+ MemberFixture.createWithdrawnMember("탈퇴회원", "탈퇴닉네임", 8888L));
+
+ memberExerciseRepository.save(MemberFixture.createMemberExercise(withdrawnMember, exercise));
+
+ mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.participants.list[0].isWithdrawn").value(true));
+ }
+ }
+
+ @Nested
+ @DisplayName("실패 케이스")
+ class Failure {
+
+ @Test
+ @DisplayName("존재하지 않는 운동이면 에러를 반환한다")
+ void 존재하지_않는_운동이면_에러를_반환한다() throws Exception {
+ SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+
+ mockMvc.perform(get("/api/exercises/{exerciseId}", 999L))
+ .andExpect(status().isNotFound())
+ .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(999L, "없는멤버");
+
+ 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()));
+ }
+ }
+ }
+
+ @Nested
+ @DisplayName("GET /api/exercises/{exerciseId}/for-edit - 운동 수정용 상세 조회")
+ class GetExerciseForEdit {
+
+ private Exercise exercise;
+
+ @BeforeEach
+ void setUp() {
+ Exercise exerciseForEdit = ExerciseFixture.createExerciseWithAddr(
+ party, LocalDate.of(2026, 3, 24), 18);
+ ReflectionTestUtils.setField(exerciseForEdit, "endTime", LocalTime.of(12, 30));
+ ReflectionTestUtils.setField(exerciseForEdit, "notice", "수정 공지사항");
+ exercise = exerciseRepository.save(exerciseForEdit);
+ }
+
+ @Nested
+ @DisplayName("성공 케이스")
+ class Success {
+
+ @Test
+ @DisplayName("응답의 모든 수정용 필드가 올바르게 반환된다")
+ void 응답의_모든_수정용_필드가_올바르게_반환된다() throws Exception {
+ SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+
+ mockMvc.perform(get("/api/exercises/{exerciseId}/for-edit", exercise.getId()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.date").value("2026-03-24"))
+ .andExpect(jsonPath("$.data.buildingName").value("테스트 체육관"))
+ .andExpect(jsonPath("$.data.roadAddress").value("서울특별시 강남구 테헤란로 1"))
+ .andExpect(jsonPath("$.data.latitude").value(37.5))
+ .andExpect(jsonPath("$.data.longitude").value(127.0))
+ .andExpect(jsonPath("$.data.startTime").value("10:00:00"))
+ .andExpect(jsonPath("$.data.endTime").value("12:30:00"))
+ .andExpect(jsonPath("$.data.maxCapacity").value(18))
+ .andExpect(jsonPath("$.data.allowMemberGuestsInvitation").value(true))
+ .andExpect(jsonPath("$.data.allowExternalGuests").value(false))
+ .andExpect(jsonPath("$.data.notice").value("수정 공지사항"));
+ }
+ }
+
+ @Nested
+ @DisplayName("실패 케이스")
+ class Failure {
+
+ @Test
+ @DisplayName("존재하지 않는 운동이면 에러를 반환한다")
+ void 존재하지_않는_운동이면_에러를_반환한다() throws Exception {
+ SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+
+ mockMvc.perform(get("/api/exercises/{exerciseId}/for-edit", 999L))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.code").value(ExerciseErrorCode.EXERCISE_NOT_FOUND.getCode()))
+ .andExpect(jsonPath("$.message").value(ExerciseErrorCode.EXERCISE_NOT_FOUND.getMessage()));
+ }
+ }
+ }
+
+ @Nested
+ @DisplayName("GET /api/exercises/{exerciseId}/guests - 내가 초대한 운동 게스트 조회")
+ class GetMyInvitedGuests {
+
+ private Exercise exercise;
+
+ @BeforeEach
+ void setUp() {
+ exercise = exerciseRepository.save(
+ ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().minusDays(1), 1));
+ }
+
+ @Nested
+ @DisplayName("성공 케이스")
+ class Success {
+
+ @Test
+ @DisplayName("내가_초대한_게스트만_참가번호와_대기상태와_함께_반환된다")
+ void 내가_초대한_게스트만_참가번호와_대기상태와_함께_반환된다() throws Exception {
+ SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+
+ var myFirstGuest = GuestFixture.createGuest(exercise, manager.getId(), "내게스트1", Gender.MALE);
+ guestRepository.save(myFirstGuest);
+
+ var otherGuest = GuestFixture.createGuest(exercise, normalMember.getId(), "다른사람게스트", Gender.MALE);
+ guestRepository.save(otherGuest);
+
+ var mySecondGuest = GuestFixture.createGuest(exercise, manager.getId(), "내게스트2", Gender.FEMALE);
+ guestRepository.save(mySecondGuest);
+
+ mockMvc.perform(get("/api/exercises/{exerciseId}/guests", exercise.getId()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.totalCount").value(2))
+ .andExpect(jsonPath("$.data.maleCount").value(1))
+ .andExpect(jsonPath("$.data.femaleCount").value(1))
+ .andExpect(jsonPath("$.data.list[0].guestId").isNumber())
+ .andExpect(jsonPath("$.data.list[0].isWaiting").value(false))
+ .andExpect(jsonPath("$.data.list[0].participantNumber").value(1))
+ .andExpect(jsonPath("$.data.list[0].name").value("내게스트1"))
+ .andExpect(jsonPath("$.data.list[0].gender").value("MALE"))
+ .andExpect(jsonPath("$.data.list[0].level").value("B"))
+ .andExpect(jsonPath("$.data.list[0].inviterName").value(manager.getMemberName()))
+ .andExpect(jsonPath("$.data.list[1].guestId").isNumber())
+ .andExpect(jsonPath("$.data.list[1].isWaiting").value(true))
+ .andExpect(jsonPath("$.data.list[1].participantNumber").value(2))
+ .andExpect(jsonPath("$.data.list[1].name").value("내게스트2"))
+ .andExpect(jsonPath("$.data.list[1].gender").value("FEMALE"))
+ .andExpect(jsonPath("$.data.list[1].level").value("B"))
+ .andExpect(jsonPath("$.data.list[1].inviterName").value(manager.getMemberName()));
+ }
+ }
+
+ @Nested
+ @DisplayName("실패 케이스")
+ class Failure {
+
+ @Test
+ @DisplayName("존재하지_않는_운동이면_에러를_반환한다")
+ void 존재하지_않는_운동이면_에러를_반환한다() throws Exception {
+ SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+
+ mockMvc.perform(get("/api/exercises/{exerciseId}/guests", 999L))
+ .andExpect(status().isNotFound())
+ .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(999L, "없는멤버");
+
+ 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()));
+ }
+ }
+ }
+
+ @Nested
+ @DisplayName("GET /api/parties/{partyId}/exercises/calender - 모임 운동 캘린더 조회")
+ class GetPartyExerciseCalendar {
+
+ private Exercise exercise;
+ private LocalDate startDate;
+ private LocalDate endDate;
+
+ @BeforeEach
+ void setUp() {
+ startDate = LocalDate.of(2026, 3, 23);
+ endDate = LocalDate.of(2026, 3, 29);
+
+ exercise = exerciseRepository.save(
+ ExerciseFixture.createExerciseWithAddr(party, LocalDate.of(2026, 3, 24)));
+ }
+
+ @Nested
+ @DisplayName("성공 케이스")
+ class Success {
+
+ @Test
+ @DisplayName("요청한 기간의 모임 운동 캘린더가 반환된다")
+ void 요청한_기간의_모임_운동_캘린더가_반환된다() throws Exception {
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+ memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, exercise));
+
+ mockMvc.perform(get("/api/parties/{partyId}/exercises/calender", party.getId())
+ .param("startDate", startDate.toString())
+ .param("endDate", endDate.toString()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.startDate").value("2026-03-23"))
+ .andExpect(jsonPath("$.data.endDate").value("2026-03-29"))
+ .andExpect(jsonPath("$.data.isMember").value(true))
+ .andExpect(jsonPath("$.data.partyName").value("테스트 모임"))
+ .andExpect(jsonPath("$.data.weeks[0].weekStartDate").value("2026-03-23"))
+ .andExpect(jsonPath("$.data.weeks[0].weekEndDate").value("2026-03-29"))
+ .andExpect(jsonPath("$.data.weeks[0].days[1].date").value("2026-03-24"))
+ .andExpect(jsonPath("$.data.weeks[0].days[1].dayOfWeek").value("TUESDAY"))
+ .andExpect(jsonPath("$.data.weeks[0].days[1].exercises[0].exerciseId").value(exercise.getId()))
+ .andExpect(jsonPath("$.data.weeks[0].days[1].exercises[0].isBookmarked").value(false))
+ .andExpect(jsonPath("$.data.weeks[0].days[1].exercises[0].buildingName").value("테스트 체육관"))
+ .andExpect(jsonPath("$.data.weeks[0].days[1].exercises[0].currentParticipants").value(1))
+ .andExpect(jsonPath("$.data.weeks[0].days[1].exercises[0].maxCapacity").value(10))
+ .andExpect(jsonPath("$.data.weeks[0].days[1].exercises[0].isParticipating").value(true));
+ }
+
+ @Test
+ @DisplayName("기간 내 운동이 없으면 빈 캘린더를 반환한다")
+ void 기간_내_운동이_없으면_빈_캘린더를_반환한다() throws Exception {
+ SecurityContextHelper.setAuthentication(outsider.getId(), outsider.getNickname());
+
+ mockMvc.perform(get("/api/parties/{partyId}/exercises/calender", party.getId())
+ .param("startDate", "2026-03-30")
+ .param("endDate", "2026-04-05"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.startDate").value("2026-03-30"))
+ .andExpect(jsonPath("$.data.endDate").value("2026-04-05"))
+ .andExpect(jsonPath("$.data.isMember").value(false))
+ .andExpect(jsonPath("$.data.partyName").value("테스트 모임"))
+ .andExpect(jsonPath("$.data.weeks").isEmpty());
+ }
+
+ @Test
+ @DisplayName("시작일과_종료일이_없으면_기본_기간이_적용된다")
+ void 시작일과_종료일이_없으면_기본_기간이_적용된다() throws Exception {
+ LocalDate expectedStart = ExerciseCalendarTestHelper.expectedDefaultStartDate();
+ LocalDate defaultExerciseDate = expectedStart.plusDays(9);
+ int weekIndex = ExerciseCalendarTestHelper.weekIndexFor(expectedStart, defaultExerciseDate);
+ int dayIndex = ExerciseCalendarTestHelper.dayIndexFor(defaultExerciseDate);
+
+ Exercise defaultExercise = exerciseRepository.save(
+ ExerciseFixture.createExerciseWithAddr(party, defaultExerciseDate));
+
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+ memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, defaultExercise));
+
+ mockMvc.perform(get("/api/parties/{partyId}/exercises/calender", party.getId()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.startDate").value(expectedStart.toString()))
+ .andExpect(jsonPath("$.data.endDate").value(ExerciseCalendarTestHelper.expectedDefaultEndDate().toString()))
+ .andExpect(jsonPath("$.data.weeks[" + weekIndex + "].days[" + dayIndex + "].date").value(defaultExerciseDate.toString()))
+ .andExpect(jsonPath("$.data.weeks[" + weekIndex + "].days[" + dayIndex + "].exercises[0].exerciseId").value(defaultExercise.getId()))
+ .andExpect(jsonPath("$.data.weeks[" + weekIndex + "].days[" + dayIndex + "].exercises[0].isParticipating").value(true));
+ }
+ }
+
+ @Nested
+ @DisplayName("실패 케이스")
+ class Failure {
+
+ @Test
+ @DisplayName("시작일과 종료일이 함께 오지 않으면 에러를 반환한다")
+ void 시작일과_종료일이_함께_오지_않으면_에러를_반환한다() throws Exception {
+ SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+
+ mockMvc.perform(get("/api/parties/{partyId}/exercises/calender", party.getId())
+ .param("startDate", startDate.toString()))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(ExerciseErrorCode.INCOMPLETE_DATE_RANGE.getCode()))
+ .andExpect(jsonPath("$.message").value(ExerciseErrorCode.INCOMPLETE_DATE_RANGE.getMessage()));
+ }
+ }
+ }
+
+ @Nested
+ @DisplayName("GET /api/exercises/my/calender - 내 운동 캘린더 조회")
+ class GetMyExerciseCalendar {
+
+ private Exercise exercise;
+ private LocalDate startDate;
+ private LocalDate endDate;
+
+ @BeforeEach
+ void setUp() {
+ startDate = LocalDate.of(2026, 3, 23);
+ endDate = LocalDate.of(2026, 3, 29);
+
+ exercise = exerciseRepository.save(
+ ExerciseFixture.createExerciseWithAddr(party, LocalDate.of(2026, 3, 25)));
+ }
+
+ @Nested
+ @DisplayName("성공 케이스")
+ class Success {
+
+ @Test
+ @DisplayName("요청한 기간의 내 운동 캘린더가 반환된다")
+ void 요청한_기간의_내_운동_캘린더가_반환된다() throws Exception {
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+ memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, exercise));
+
+ mockMvc.perform(get("/api/exercises/my/calender")
+ .param("startDate", startDate.toString())
+ .param("endDate", endDate.toString()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.startDate").value("2026-03-23"))
+ .andExpect(jsonPath("$.data.endDate").value("2026-03-29"))
+ .andExpect(jsonPath("$.data.weeks[0].weekStartDate").value("2026-03-23"))
+ .andExpect(jsonPath("$.data.weeks[0].weekEndDate").value("2026-03-29"))
+ .andExpect(jsonPath("$.data.weeks[0].days[2].date").value("2026-03-25"))
+ .andExpect(jsonPath("$.data.weeks[0].days[2].dayOfWeek").value("WEDNESDAY"))
+ .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].exerciseId").value(exercise.getId()))
+ .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].partyId").value(party.getId()))
+ .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].partyName").value("테스트 모임"))
+ .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].buildingName").value("테스트 체육관"))
+ .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].startTime").value("10:00:00"))
+ .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].endTime").value(nullValue()))
+ .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].profileImageUrl").value(nullValue()));
+ }
+
+ @Test
+ @DisplayName("기간 내 참여 운동이 없으면 빈 캘린더를 반환한다")
+ void 기간_내_참여_운동이_없으면_빈_캘린더를_반환한다() throws Exception {
+ SecurityContextHelper.setAuthentication(outsider.getId(), outsider.getNickname());
+
+ mockMvc.perform(get("/api/exercises/my/calender")
+ .param("startDate", "2026-03-30")
+ .param("endDate", "2026-04-05"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.startDate").value("2026-03-30"))
+ .andExpect(jsonPath("$.data.endDate").value("2026-04-05"))
+ .andExpect(jsonPath("$.data.weeks").isEmpty());
+ }
+
+ @Test
+ @DisplayName("시작일과_종료일이_없으면_기본_기간이_적용된다")
+ void 시작일과_종료일이_없으면_기본_기간이_적용된다() throws Exception {
+ LocalDate expectedStart = ExerciseCalendarTestHelper.expectedDefaultStartDate();
+ LocalDate defaultExerciseDate = expectedStart.plusDays(8);
+ int weekIndex = ExerciseCalendarTestHelper.weekIndexFor(expectedStart, defaultExerciseDate);
+ int dayIndex = ExerciseCalendarTestHelper.dayIndexFor(defaultExerciseDate);
+
+ Exercise defaultExercise = exerciseRepository.save(
+ ExerciseFixture.createExerciseWithAddr(party, defaultExerciseDate));
+
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+ memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, defaultExercise));
+
+ mockMvc.perform(get("/api/exercises/my/calender"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.startDate").value(expectedStart.toString()))
+ .andExpect(jsonPath("$.data.endDate").value(ExerciseCalendarTestHelper.expectedDefaultEndDate().toString()))
+ .andExpect(jsonPath("$.data.weeks[" + weekIndex + "].days[" + dayIndex + "].date").value(defaultExerciseDate.toString()))
+ .andExpect(jsonPath("$.data.weeks[" + weekIndex + "].days[" + dayIndex + "].exercises[0].exerciseId").value(defaultExercise.getId()))
+ .andExpect(jsonPath("$.data.weeks[" + weekIndex + "].days[" + dayIndex + "].exercises[0].partyId").value(party.getId()));
+ }
+ }
+
+ @Nested
+ @DisplayName("실패 케이스")
+ class Failure {
+
+ @Test
+ @DisplayName("시작일과 종료일이 함께 오지 않으면 에러를 반환한다")
+ void 시작일과_종료일이_함께_오지_않으면_에러를_반환한다() throws Exception {
+ SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+
+ mockMvc.perform(get("/api/exercises/my/calender")
+ .param("startDate", startDate.toString()))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(ExerciseErrorCode.INCOMPLETE_DATE_RANGE.getCode()))
+ .andExpect(jsonPath("$.message").value(ExerciseErrorCode.INCOMPLETE_DATE_RANGE.getMessage()));
+ }
+ }
+ }
+
+ @Nested
+ @DisplayName("GET /api/exercises/parties/my - 내 모임 운동 조회")
+ class GetMyPartyExercise {
+
+ @Nested
+ @DisplayName("성공 케이스")
+ class Success {
+
+ @Test
+ @DisplayName("시작한 운동은 제외하고 내가 속한 모임의 예정된 운동을 최대 6개까지 시간순으로 반환한다")
+ void 시작한_운동은_제외하고_내가_속한_모임의_예정된_운동을_최대_6개까지_시간순으로_반환한다() throws Exception {
+ PartyAddr otherAddr = partyAddrRepository.save(PartyFixture.createPartyAddr("서울특별시", "송파구"));
+ Party otherParty = partyRepository.save(PartyFixture.createParty("다른 모임", outsider.getId(), otherAddr));
+
+ Exercise pastExercise = exerciseRepository.save(
+ ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().minusDays(1)));
+ Exercise startedTodayExercise = ExerciseFixture.createExerciseWithAddr(party, LocalDate.now());
+ ReflectionTestUtils.setField(startedTodayExercise, "startTime", LocalTime.now().minusMinutes(30));
+ startedTodayExercise = exerciseRepository.save(startedTodayExercise);
+ Exercise firstExercise = exerciseRepository.save(
+ ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().plusDays(1)));
+ Exercise secondExercise = exerciseRepository.save(
+ ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().plusDays(2)));
+ Exercise thirdExercise = exerciseRepository.save(
+ ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().plusDays(3)));
+ Exercise fourthExercise = exerciseRepository.save(
+ ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().plusDays(4)));
+ Exercise fifthExercise = exerciseRepository.save(
+ ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().plusDays(5)));
+ Exercise sixthExercise = exerciseRepository.save(
+ ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().plusDays(6)));
+ exerciseRepository.save(ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().plusDays(7)));
+ exerciseRepository.save(ExerciseFixture.createExerciseWithAddr(otherParty, LocalDate.now().plusDays(1)));
+
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+
+ mockMvc.perform(get("/api/exercises/parties/my"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.totalExercises").value(6))
+ .andExpect(jsonPath("$.data.exercises.length()").value(6))
+ .andExpect(jsonPath("$.data.exercises[0].exerciseId").value(firstExercise.getId()))
+ .andExpect(jsonPath("$.data.exercises[1].exerciseId").value(secondExercise.getId()))
+ .andExpect(jsonPath("$.data.exercises[2].exerciseId").value(thirdExercise.getId()))
+ .andExpect(jsonPath("$.data.exercises[3].exerciseId").value(fourthExercise.getId()))
+ .andExpect(jsonPath("$.data.exercises[4].exerciseId").value(fifthExercise.getId()))
+ .andExpect(jsonPath("$.data.exercises[5].exerciseId").value(sixthExercise.getId()))
+ .andExpect(jsonPath("$.data.exercises[0].partyId").value(party.getId()))
+ .andExpect(jsonPath("$.data.exercises[0].partyName").value("테스트 모임"))
+ .andExpect(jsonPath("$.data.exercises[0].buildingName").value("테스트 체육관"))
+ .andExpect(jsonPath("$.data.exercises[0].profileImageUrl").value(nullValue()));
+ }
+
+ @Test
+ @DisplayName("속한 모임이 없으면 빈 응답을 반환한다")
+ void 속한_모임이_없으면_빈_응답을_반환한다() throws Exception {
+ SecurityContextHelper.setAuthentication(outsider.getId(), outsider.getNickname());
+
+ mockMvc.perform(get("/api/exercises/parties/my"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.totalExercises").value(0))
+ .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
+ @DisplayName("GET /api/exercises/parties/my/calendar - 내 모임 운동 캘린더 조회")
+ class GetMyPartyExerciseCalendar {
+
+ private Exercise exercise;
+ private LocalDate startDate;
+ private LocalDate endDate;
+
+ @BeforeEach
+ void setUp() {
+ startDate = LocalDate.of(2026, 3, 23);
+ endDate = LocalDate.of(2026, 3, 29);
+
+ exercise = exerciseRepository.save(
+ ExerciseFixture.createExerciseWithAddr(party, LocalDate.of(2026, 3, 25)));
+ }
+
+ @Nested
+ @DisplayName("성공 케이스")
+ class Success {
+
+ @Test
+ @DisplayName("요청한 기간의 내 모임 운동 캘린더가 반환된다")
+ void 요청한_기간의_내_모임_운동_캘린더가_반환된다() throws Exception {
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+
+ mockMvc.perform(get("/api/exercises/parties/my/calendar")
+ .param("startDate", startDate.toString())
+ .param("endDate", endDate.toString()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.startDate").value("2026-03-23"))
+ .andExpect(jsonPath("$.data.endDate").value("2026-03-29"))
+ .andExpect(jsonPath("$.data.weeks[0].weekStartDate").value("2026-03-23"))
+ .andExpect(jsonPath("$.data.weeks[0].weekEndDate").value("2026-03-29"))
+ .andExpect(jsonPath("$.data.weeks[0].days[2].date").value("2026-03-25"))
+ .andExpect(jsonPath("$.data.weeks[0].days[2].dayOfWeek").value("WEDNESDAY"))
+ .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].exerciseId").value(exercise.getId()))
+ .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].partyId").value(party.getId()))
+ .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].partyName").value("테스트 모임"))
+ .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].buildingName").value("테스트 체육관"))
+ .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].isBookmarked").value(false))
+ .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].nowCapacity").value(0));
+ }
+
+ @Test
+ @DisplayName("속한 모임이 없으면 빈 캘린더를 반환한다")
+ void 속한_모임이_없으면_빈_캘린더를_반환한다() throws Exception {
+ SecurityContextHelper.setAuthentication(outsider.getId(), outsider.getNickname());
+
+ mockMvc.perform(get("/api/exercises/parties/my/calendar")
+ .param("startDate", startDate.toString())
+ .param("endDate", endDate.toString()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.startDate").value("2026-03-23"))
+ .andExpect(jsonPath("$.data.endDate").value("2026-03-29"))
+ .andExpect(jsonPath("$.data.weeks").isEmpty());
+ }
+
+ @Test
+ @DisplayName("기간 내 내 모임 운동이 없으면 빈 캘린더를 반환한다")
+ void 기간_내_내_모임_운동이_없으면_빈_캘린더를_반환한다() throws Exception {
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+
+ mockMvc.perform(get("/api/exercises/parties/my/calendar")
+ .param("startDate", "2026-03-30")
+ .param("endDate", "2026-04-05"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.startDate").value("2026-03-30"))
+ .andExpect(jsonPath("$.data.endDate").value("2026-04-05"))
+ .andExpect(jsonPath("$.data.weeks").isEmpty());
+ }
+
+ @Test
+ @DisplayName("시작일과_종료일이_없으면_기본_기간이_적용된다")
+ void 시작일과_종료일이_없으면_기본_기간이_적용된다() throws Exception {
+ LocalDate expectedStart = ExerciseCalendarTestHelper.expectedDefaultStartDate();
+ LocalDate defaultExerciseDate = expectedStart.plusDays(8);
+ int weekIndex = ExerciseCalendarTestHelper.weekIndexFor(expectedStart, defaultExerciseDate);
+ int dayIndex = ExerciseCalendarTestHelper.dayIndexFor(defaultExerciseDate);
+
+ Exercise defaultExercise = exerciseRepository.save(
+ ExerciseFixture.createExerciseWithAddr(party, defaultExerciseDate));
+
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+
+ mockMvc.perform(get("/api/exercises/parties/my/calendar"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.startDate").value(expectedStart.toString()))
+ .andExpect(jsonPath("$.data.endDate").value(ExerciseCalendarTestHelper.expectedDefaultEndDate().toString()))
+ .andExpect(jsonPath("$.data.weeks[" + weekIndex + "].days[" + dayIndex + "].date").value(defaultExerciseDate.toString()))
+ .andExpect(jsonPath("$.data.weeks[" + weekIndex + "].days[" + dayIndex + "].exercises[0].exerciseId").value(defaultExercise.getId()))
+ .andExpect(jsonPath("$.data.weeks[" + weekIndex + "].days[" + dayIndex + "].exercises[0].partyId").value(party.getId()));
+ }
+
+ @Test
+ @DisplayName("POPULARITY 정렬 옵션으로 조회 시 정상 반환된다")
+ void POPULARITY_정렬_옵션으로_조회_시_정상_반환된다() throws Exception {
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+
+ mockMvc.perform(get("/api/exercises/parties/my/calendar")
+ .param("orderType", "POPULARITY")
+ .param("startDate", startDate.toString())
+ .param("endDate", endDate.toString()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.startDate").value("2026-03-23"))
+ .andExpect(jsonPath("$.data.endDate").value("2026-03-29"))
+ .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
+ @DisplayName("GET /api/exercises/my - 내 참여 운동 조회")
+ class GetMyExercises {
+
+ private final List upcomingExercises = new ArrayList<>();
+ private final List completedExercises = new ArrayList<>();
+
+ @BeforeEach
+ void setUp() {
+ party.addLevel(Gender.FEMALE, Level.B);
+ party.addLevel(Gender.MALE, Level.A);
+ partyRepository.save(party);
+
+ for (int day = 1; day <= 10; day++) {
+ Exercise exercise = saveParticipatedExercise(LocalDate.of(2099, 1, day), LocalTime.of(10, 0), 10, true);
+ upcomingExercises.add(exercise);
+ }
+
+ Exercise featuredUpcomingExercise = upcomingExercises.get(9);
+ ReflectionTestUtils.setField(featuredUpcomingExercise, "startTime", LocalTime.of(7, 30));
+ ReflectionTestUtils.setField(featuredUpcomingExercise, "endTime", LocalTime.of(9, 0));
+ ReflectionTestUtils.setField(featuredUpcomingExercise, "maxCapacity", 20);
+ ReflectionTestUtils.setField(featuredUpcomingExercise, "partyGuestAccept", false);
+ featuredUpcomingExercise = exerciseRepository.save(featuredUpcomingExercise);
+ upcomingExercises.set(9, featuredUpcomingExercise);
+
+ memberExerciseRepository.save(MemberFixture.createMemberExercise(subManager, featuredUpcomingExercise));
+ guestRepository.save(GuestFixture.createGuest(featuredUpcomingExercise, manager.getId()));
+ exerciseBookmarkRepository.save(ExerciseBookmark.builder()
+ .member(normalMember)
+ .exercise(featuredUpcomingExercise)
+ .build());
+
+ for (int day = 1; day <= 8; day++) {
+ Exercise exercise = saveParticipatedExercise(LocalDate.of(2024, 1, day), LocalTime.of(10, 0), 10, true);
+ completedExercises.add(exercise);
+ }
+
+ exerciseRepository.save(ExerciseFixture.createExerciseWithAddr(party, LocalDate.of(2099, 2, 1)));
+ }
+
+ @Nested
+ @DisplayName("성공 케이스")
+ class Success {
+
+ @Test
+ @DisplayName("파라미터 없이 호출하면 ALL 최신순 기본값과 15개 페이징이 적용된다")
+ void 파라미터_없이_호출하면_ALL_최신순_기본값과_15개_페이징이_적용된다() throws Exception {
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+
+ mockMvc.perform(get("/api/exercises/my"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.totalCount").value(15))
+ .andExpect(jsonPath("$.data.hasNext").value(true))
+ .andExpect(jsonPath("$.data.exercises.length()").value(15))
+ .andExpect(jsonPath("$.data.exercises[0].exerciseId").value(upcomingExercises.get(9).getId()))
+ .andExpect(jsonPath("$.data.exercises[0].partyId").value(party.getId()))
+ .andExpect(jsonPath("$.data.exercises[0].partyName").value("테스트 모임"))
+ .andExpect(jsonPath("$.data.exercises[0].isBookmarked").value(true))
+ .andExpect(jsonPath("$.data.exercises[0].date").value("2099-01-10"))
+ .andExpect(jsonPath("$.data.exercises[0].dayOfWeek").value("SATURDAY"))
+ .andExpect(jsonPath("$.data.exercises[0].buildingName").value("테스트 체육관"))
+ .andExpect(jsonPath("$.data.exercises[0].startTime").value("07:30:00"))
+ .andExpect(jsonPath("$.data.exercises[0].endTime").value("09:00:00"))
+ .andExpect(jsonPath("$.data.exercises[0].femaleLevel[0]").value("B조"))
+ .andExpect(jsonPath("$.data.exercises[0].maleLevel[0]").value("A조"))
+ .andExpect(jsonPath("$.data.exercises[0].currentParticipants").value(3))
+ .andExpect(jsonPath("$.data.exercises[0].maxCapacity").value(20))
+ .andExpect(jsonPath("$.data.exercises[0].isCompleted").value(false))
+ .andExpect(jsonPath("$.data.exercises[0].partyGuestInviteAccept").value(false))
+ .andExpect(jsonPath("$.data.exercises[14].exerciseId").value(completedExercises.get(3).getId()));
+ }
+
+ @Test
+ @DisplayName("두 번째 페이지를 조회하면 남은 3개 운동과 hasNext false를 반환한다")
+ void 두_번째_페이지를_조회하면_남은_3개_운동과_hasNext_false를_반환한다() throws Exception {
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+
+ mockMvc.perform(get("/api/exercises/my")
+ .param("page", "1"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.totalCount").value(3))
+ .andExpect(jsonPath("$.data.hasNext").value(false))
+ .andExpect(jsonPath("$.data.exercises.length()").value(3))
+ .andExpect(jsonPath("$.data.exercises[0].exerciseId").value(completedExercises.get(2).getId()))
+ .andExpect(jsonPath("$.data.exercises[2].exerciseId").value(completedExercises.get(0).getId()));
+ }
+
+ @Test
+ @DisplayName("UPCOMING 필터는 예정 운동만 최신순 기본정렬로 반환한다")
+ void UPCOMING_필터는_예정_운동만_최신순_기본정렬로_반환한다() throws Exception {
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+
+ mockMvc.perform(get("/api/exercises/my")
+ .param("filterType", "UPCOMING"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.totalCount").value(10))
+ .andExpect(jsonPath("$.data.hasNext").value(false))
+ .andExpect(jsonPath("$.data.exercises[0].exerciseId").value(upcomingExercises.get(0).getId()))
+ .andExpect(jsonPath("$.data.exercises[0].isCompleted").value(false))
+ .andExpect(jsonPath("$.data.exercises[9].exerciseId").value(upcomingExercises.get(9).getId()))
+ .andExpect(jsonPath("$.data.exercises[9].date").value("2099-01-10"));
+ }
+
+ @Test
+ @DisplayName("UPCOMING 필터에 OLDEST 정렬을 주면 반대 순서로 반환한다")
+ void UPCOMING_필터에_OLDEST_정렬을_주면_반대_순서로_반환한다() throws Exception {
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+
+ mockMvc.perform(get("/api/exercises/my")
+ .param("filterType", "UPCOMING")
+ .param("orderType", "OLDEST"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.totalCount").value(10))
+ .andExpect(jsonPath("$.data.exercises[0].exerciseId").value(upcomingExercises.get(9).getId()))
+ .andExpect(jsonPath("$.data.exercises[9].exerciseId").value(upcomingExercises.get(0).getId()));
+ }
+
+ @Test
+ @DisplayName("COMPLETED 필터는 완료 운동만 최신순 기본정렬로 반환한다")
+ void COMPLETED_필터는_완료_운동만_최신순_기본정렬로_반환한다() throws Exception {
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+
+ mockMvc.perform(get("/api/exercises/my")
+ .param("filterType", "COMPLETED"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.totalCount").value(8))
+ .andExpect(jsonPath("$.data.hasNext").value(false))
+ .andExpect(jsonPath("$.data.exercises[0].exerciseId").value(completedExercises.get(7).getId()))
+ .andExpect(jsonPath("$.data.exercises[0].isCompleted").value(true))
+ .andExpect(jsonPath("$.data.exercises[7].exerciseId").value(completedExercises.get(0).getId()));
+ }
+
+ @Test
+ @DisplayName("COMPLETED 필터에 OLDEST 정렬을 주면 반대 순서로 반환한다")
+ void COMPLETED_필터에_OLDEST_정렬을_주면_반대_순서로_반환한다() throws Exception {
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+
+ mockMvc.perform(get("/api/exercises/my")
+ .param("filterType", "COMPLETED")
+ .param("orderType", "OLDEST"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.totalCount").value(8))
+ .andExpect(jsonPath("$.data.exercises[0].exerciseId").value(completedExercises.get(0).getId()))
+ .andExpect(jsonPath("$.data.exercises[7].exerciseId").value(completedExercises.get(7).getId()));
+ }
+
+ @Test
+ @DisplayName("ALL 필터에 OLDEST 정렬을 주면 가장 오래된 완료 운동부터 반환한다")
+ void ALL_필터에_OLDEST_정렬을_주면_가장_오래된_완료_운동부터_반환한다() throws Exception {
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+
+ mockMvc.perform(get("/api/exercises/my")
+ .param("orderType", "OLDEST"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.totalCount").value(15))
+ .andExpect(jsonPath("$.data.hasNext").value(true))
+ .andExpect(jsonPath("$.data.exercises[0].exerciseId").value(completedExercises.get(0).getId()))
+ .andExpect(jsonPath("$.data.exercises[14].exerciseId").value(upcomingExercises.get(6).getId()));
+ }
+
+ @Test
+ @DisplayName("참여한 운동이 없으면 빈 응답을 반환한다")
+ void 참여한_운동이_없으면_빈_응답을_반환한다() throws Exception {
+ SecurityContextHelper.setAuthentication(outsider.getId(), outsider.getNickname());
+
+ mockMvc.perform(get("/api/exercises/my"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.totalCount").value(0))
+ .andExpect(jsonPath("$.data.hasNext").value(false))
+ .andExpect(jsonPath("$.data.exercises").isEmpty());
+ }
+ }
+
+ @Nested
+ @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 {
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+
+ mockMvc.perform(get("/api/exercises/my")
+ .param("filterType", "INVALID"))
+ .andExpect(status().isBadRequest());
+ }
+
+ @Test
+ @DisplayName("잘못된 정렬 타입이면 400을 반환한다")
+ void 잘못된_정렬_타입이면_400을_반환한다() throws Exception {
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+
+ mockMvc.perform(get("/api/exercises/my")
+ .param("orderType", "INVALID"))
+ .andExpect(status().isBadRequest());
+ }
+ }
+
+ private Exercise saveParticipatedExercise(LocalDate date, LocalTime startTime,
+ int maxCapacity, boolean partyGuestAccept) {
+ Exercise exercise = ExerciseFixture.createExerciseWithAddr(party, date, maxCapacity);
+ ReflectionTestUtils.setField(exercise, "startTime", startTime);
+ ReflectionTestUtils.setField(exercise, "partyGuestAccept", partyGuestAccept);
+
+ Exercise savedExercise = exerciseRepository.save(exercise);
+ memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, savedExercise));
+ return savedExercise;
+ }
+ }
+
+ @Nested
+ @DisplayName("GET /api/buildings/exercises/{date} - 건물 운동 상세 조회")
+ class GetBuildingExerciseDetails {
+
+ private final LocalDate targetDate = LocalDate.of(2026, 5, 10);
+ private final String targetBuildingName = "콕플 타워";
+ private final String targetStreetAddr = "서울특별시 강남구 테헤란로 10";
+ private Exercise morningExercise;
+ private Exercise eveningExercise;
+
+ @BeforeEach
+ void setUp() {
+ eveningExercise = saveBuildingExercise(targetBuildingName, targetStreetAddr,
+ targetDate, LocalTime.of(19, 0), LocalTime.of(21, 0));
+ morningExercise = saveBuildingExercise(targetBuildingName, targetStreetAddr,
+ targetDate, LocalTime.of(9, 0), LocalTime.of(11, 0));
+
+ exerciseBookmarkRepository.save(ExerciseBookmark.builder()
+ .member(normalMember)
+ .exercise(eveningExercise)
+ .build());
+
+ saveBuildingExercise("다른 건물", targetStreetAddr,
+ targetDate, LocalTime.of(13, 0), LocalTime.of(15, 0));
+ saveBuildingExercise(targetBuildingName, "서울특별시 강남구 테헤란로 99",
+ targetDate, LocalTime.of(16, 0), LocalTime.of(18, 0));
+ }
+
+ @Nested
+ @DisplayName("성공 케이스")
+ class Success {
+
+ @Test
+ @DisplayName("같은 건물과 주소의 운동만 시작시간 오름차순으로 반환한다")
+ void 같은_건물과_주소의_운동만_시작시간_오름차순으로_반환한다() throws Exception {
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+
+ mockMvc.perform(get("/api/buildings/exercises/{date}", targetDate)
+ .param("buildingName", targetBuildingName)
+ .param("streetAddr", targetStreetAddr))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.date").value("2026-05-10"))
+ .andExpect(jsonPath("$.data.dayOfWeek").value("SUNDAY"))
+ .andExpect(jsonPath("$.data.buildingName").value(targetBuildingName))
+ .andExpect(jsonPath("$.data.exercises.length()").value(2))
+ .andExpect(jsonPath("$.data.exercises[0].exerciseId").value(morningExercise.getId()))
+ .andExpect(jsonPath("$.data.exercises[0].partyId").value(party.getId()))
+ .andExpect(jsonPath("$.data.exercises[0].partyName").value("테스트 모임"))
+ .andExpect(jsonPath("$.data.exercises[0].profileImageUrl").isEmpty())
+ .andExpect(jsonPath("$.data.exercises[0].isBookmarked").value(false))
+ .andExpect(jsonPath("$.data.exercises[0].startTime").value("09:00:00"))
+ .andExpect(jsonPath("$.data.exercises[0].endTime").value("11:00:00"))
+ .andExpect(jsonPath("$.data.exercises[1].exerciseId").value(eveningExercise.getId()))
+ .andExpect(jsonPath("$.data.exercises[1].isBookmarked").value(true))
+ .andExpect(jsonPath("$.data.exercises[1].startTime").value("19:00:00"))
+ .andExpect(jsonPath("$.data.exercises[1].endTime").value("21:00:00"));
+ }
+
+ @Test
+ @DisplayName("해당 건물 운동이 없으면 메타데이터가 포함된 빈 응답을 반환한다")
+ void 해당_건물_운동이_없으면_메타데이터가_포함된_빈_응답을_반환한다() throws Exception {
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+
+ mockMvc.perform(get("/api/buildings/exercises/{date}", targetDate)
+ .param("buildingName", "없는 건물")
+ .param("streetAddr", targetStreetAddr))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.date").value("2026-05-10"))
+ .andExpect(jsonPath("$.data.dayOfWeek").value("SUNDAY"))
+ .andExpect(jsonPath("$.data.buildingName").value("없는 건물"))
+ .andExpect(jsonPath("$.data.exercises").isEmpty());
+ }
+ }
+
+ @Nested
+ @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 {
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+
+ mockMvc.perform(get("/api/buildings/exercises/{date}", targetDate)
+ .param("streetAddr", targetStreetAddr))
+ .andExpect(status().isBadRequest());
+ }
+
+ @Test
+ @DisplayName("streetAddr이 없으면 400을 반환한다")
+ void streetAddr이_없으면_400을_반환한다() throws Exception {
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+
+ mockMvc.perform(get("/api/buildings/exercises/{date}", targetDate)
+ .param("buildingName", targetBuildingName))
+ .andExpect(status().isBadRequest());
+ }
+
+ @Test
+ @DisplayName("날짜 형식이 잘못되면 400을 반환한다")
+ void 날짜_형식이_잘못되면_400을_반환한다() throws Exception {
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+
+ mockMvc.perform(get("/api/buildings/exercises/{date}", "invalid-date")
+ .param("buildingName", targetBuildingName)
+ .param("streetAddr", targetStreetAddr))
+ .andExpect(status().isBadRequest());
+ }
+ }
+
+ private Exercise saveBuildingExercise(String buildingName, String streetAddr,
+ LocalDate date, LocalTime startTime, LocalTime endTime) {
+ Exercise buildingExercise = ExerciseFixture.createExerciseWithAddr(party, date, 12);
+ ReflectionTestUtils.setField(buildingExercise, "exerciseAddr",
+ ExerciseFixture.createExerciseAddr(buildingName, streetAddr));
+ ReflectionTestUtils.setField(buildingExercise, "startTime", startTime);
+ ReflectionTestUtils.setField(buildingExercise, "endTime", endTime);
+ return exerciseRepository.save(buildingExercise);
+ }
+ }
+
+ @Nested
+ @DisplayName("GET /api/buildings/map/monthly - 월간 운동 건물 지도 데이터 조회")
+ class GetMonthlyExerciseBuildings {
+
+ private final LocalDate targetDate = LocalDate.of(2026, 4, 15);
+ private Member memberWithoutMainAddr;
+
+ @BeforeEach
+ void setUp() {
+ saveMemberAddr(normalMember, "서울특별시", "강남구", "역삼동",
+ "서울특별시 강남구 테헤란로 1", "대표주소", 37.5, 127.0, true);
+
+ memberWithoutMainAddr = memberRepository.save(
+ MemberFixture.createMember("대표주소없음", Gender.FEMALE, Level.B, 1010L, LocalDate.of(2000, 1, 1)));
+ saveMemberAddr(memberWithoutMainAddr, "서울특별시", "송파구", "잠실동",
+ "서울특별시 송파구 올림픽로 1", "보조주소", 37.514, 127.102, false);
+
+ saveMapExercise(LocalDate.of(2026, 4, 3), "A빌딩", "서울특별시 강남구 테헤란로 10",
+ 37.5005, 127.0005, LocalTime.of(9, 0));
+ saveMapExercise(LocalDate.of(2026, 4, 3), "A빌딩", "서울특별시 강남구 테헤란로 10",
+ 37.5005, 127.0005, LocalTime.of(19, 0));
+ saveMapExercise(LocalDate.of(2026, 4, 3), "B빌딩", "서울특별시 강남구 테헤란로 20",
+ 37.501, 127.001, LocalTime.of(13, 0));
+ saveMapExercise(LocalDate.of(2026, 4, 4), "A빌딩", "서울특별시 강남구 테헤란로 10",
+ 37.5005, 127.0005, LocalTime.of(10, 0));
+ saveMapExercise(LocalDate.of(2026, 4, 5), "반경밖빌딩", "부산광역시 해운대구 센텀로 1",
+ 35.17, 129.13, LocalTime.of(12, 0));
+ saveMapExercise(LocalDate.of(2026, 4, 6), "부산빌딩", "부산광역시 해운대구 센텀로 2",
+ 35.1705, 129.1305, LocalTime.of(14, 0));
+ }
+
+ @Nested
+ @DisplayName("성공 케이스")
+ class Success {
+
+ @Test
+ @DisplayName("기본 요청은 현재 월과 대표주소 중심 좌표를 사용한다")
+ void 기본_요청은_현재_월과_대표주소_중심_좌표를_사용한다() throws Exception {
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+
+ mockMvc.perform(get("/api/buildings/map/monthly"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.year").value(LocalDate.now().getYear()))
+ .andExpect(jsonPath("$.data.month").value(LocalDate.now().getMonthValue()))
+ .andExpect(jsonPath("$.data.centerLatitude").value(37.5))
+ .andExpect(jsonPath("$.data.centerLongitude").value(127.0))
+ .andExpect(jsonPath("$.data.radiusKm").value(3.0))
+ .andExpect(jsonPath("$.data.buildings").isMap());
+ }
+
+ @Test
+ @DisplayName("명시 날짜와 좌표로 조회하면 날짜별 건물 지도를 dedupe하여 반환한다")
+ void 명시_날짜와_좌표로_조회하면_날짜별_건물_지도를_dedupe하여_반환한다() throws Exception {
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+
+ mockMvc.perform(get("/api/buildings/map/monthly")
+ .param("date", targetDate.toString())
+ .param("latitude", "37.5")
+ .param("longitude", "127.0")
+ .param("radiusKm", "3.9"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.year").value(2026))
+ .andExpect(jsonPath("$.data.month").value(4))
+ .andExpect(jsonPath("$.data.centerLatitude").value(37.5))
+ .andExpect(jsonPath("$.data.centerLongitude").value(127.0))
+ .andExpect(jsonPath("$.data.radiusKm").value(3.9))
+ .andExpect(jsonPath("$.data.buildings['2026-04-03'].length()").value(2))
+ .andExpect(jsonPath("$.data.buildings['2026-04-03'][*].buildingName", containsInAnyOrder("A빌딩", "B빌딩")))
+ .andExpect(jsonPath("$.data.buildings['2026-04-04'].length()").value(1))
+ .andExpect(jsonPath("$.data.buildings['2026-04-04'][0].buildingName").value("A빌딩"))
+ .andExpect(jsonPath("$.data.buildings['2026-04-05']").doesNotExist());
+ }
+
+ @Test
+ @DisplayName("명시 좌표는 대표주소 대신 응답 중심 좌표로 반영된다")
+ void 명시_좌표는_대표주소_대신_응답_중심_좌표로_반영된다() throws Exception {
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+
+ mockMvc.perform(get("/api/buildings/map/monthly")
+ .param("date", targetDate.toString())
+ .param("latitude", "35.17")
+ .param("longitude", "129.13")
+ .param("radiusKm", "5.0"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.centerLatitude").value(35.17))
+ .andExpect(jsonPath("$.data.centerLongitude").value(129.13))
+ .andExpect(jsonPath("$.data.radiusKm").value(5.0))
+ .andExpect(jsonPath("$.data.buildings['2026-04-06'][0].buildingName").value("부산빌딩"));
+ }
+
+ @Test
+ @DisplayName("반경 내 운동이 없으면 빈 buildings를 반환한다")
+ void 반경_내_운동이_없으면_빈_buildings를_반환한다() throws Exception {
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+
+ mockMvc.perform(get("/api/buildings/map/monthly")
+ .param("date", targetDate.toString())
+ .param("latitude", "36.0")
+ .param("longitude", "128.0")
+ .param("radiusKm", "1.0"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.year").value(2026))
+ .andExpect(jsonPath("$.data.month").value(4))
+ .andExpect(jsonPath("$.data.buildings").isEmpty());
+ }
+ }
+
+ @Nested
+ @DisplayName("실패 케이스")
+ class Failure {
+
+ @Test
+ @DisplayName("존재하지 않는 멤버면 에러를 반환한다")
+ void 존재하지_않는_멤버면_에러를_반환한다() throws Exception {
+ SecurityContextHelper.setAuthentication(999L, "없는멤버");
+
+ 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()));
+ }
+
+ @Test
+ @DisplayName("대표주소가 없으면 에러를 반환한다")
+ void 대표주소가_없으면_에러를_반환한다() throws Exception {
+ SecurityContextHelper.setAuthentication(memberWithoutMainAddr.getId(), memberWithoutMainAddr.getNickname());
+
+ 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()));
+ }
+
+ @Test
+ @DisplayName("대표주소가 없으면 명시 좌표가 있어도 에러를 반환한다")
+ void 대표주소가_없으면_명시_좌표가_있어도_에러를_반환한다() throws Exception {
+ SecurityContextHelper.setAuthentication(memberWithoutMainAddr.getId(), memberWithoutMainAddr.getNickname());
+
+ mockMvc.perform(get("/api/buildings/map/monthly")
+ .param("date", targetDate.toString())
+ .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()));
+ }
+
+ @Test
+ @DisplayName("위도만 주면 에러를 반환한다")
+ void 위도만_주면_에러를_반환한다() throws Exception {
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+
+ mockMvc.perform(get("/api/buildings/map/monthly")
+ .param("date", targetDate.toString())
+ .param("latitude", "37.5"))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(ExerciseErrorCode.INCOMPLETE_LOCATION_INFO.getCode()))
+ .andExpect(jsonPath("$.message").value(ExerciseErrorCode.INCOMPLETE_LOCATION_INFO.getMessage()));
+ }
+
+ @Test
+ @DisplayName("날짜 형식이 잘못되면 400을 반환한다")
+ void 날짜_형식이_잘못되면_400을_반환한다() throws Exception {
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+
+ mockMvc.perform(get("/api/buildings/map/monthly")
+ .param("date", "invalid-date"))
+ .andExpect(status().isBadRequest());
+ }
+ }
+
+ private MemberAddr saveMemberAddr(Member member, String addr1, String addr2, String addr3,
+ String streetAddr, String buildingName,
+ double latitude, double longitude, boolean isMain) {
+ return memberAddrRepository.save(MemberAddr.builder()
+ .member(member)
+ .addr1(addr1)
+ .addr2(addr2)
+ .addr3(addr3)
+ .streetAddr(streetAddr)
+ .buildingName(buildingName)
+ .latitude(latitude)
+ .longitude(longitude)
+ .isMain(isMain)
+ .build());
+ }
+
+ private Exercise saveMapExercise(LocalDate date, String buildingName, String streetAddr,
+ double latitude, double longitude, LocalTime startTime) {
+ Exercise exercise = ExerciseFixture.createExerciseWithAddr(party, date, 12);
+ ReflectionTestUtils.setField(exercise, "exerciseAddr",
+ ExerciseFixture.createExerciseAddr(buildingName, streetAddr, latitude, longitude));
+ ReflectionTestUtils.setField(exercise, "startTime", startTime);
+ return exerciseRepository.save(exercise);
+ }
+ }
+
+ @Nested
+ @DisplayName("GET /api/exercises/recommendations/calendar - 사용자 추천 운동 캘린더 조회")
+ class GetRecommendedExerciseCalendar {
+
+ private Member recommendationMember;
+ private Member memberWithoutMainAddr;
+ private Party filteredParty;
+ private LocalDate startDate;
+ private LocalDate endDate;
+ private Exercise filteredEarlyExercise;
+ private Exercise filteredPopularExercise;
+
+ @BeforeEach
+ void setUp() {
+ recommendationMember = memberRepository.save(
+ MemberFixture.createMember("추천캘린더회원", Gender.MALE, Level.A, 1201L, LocalDate.of(1995, 6, 15)));
+ memberAddrRepository.save(MemberAddrFixture.createMainAddr(recommendationMember));
+
+ memberWithoutMainAddr = memberRepository.save(
+ MemberFixture.createMember("주소없는추천회원", Gender.MALE, Level.A, 1202L, LocalDate.of(1995, 6, 15)));
+
+ party.addLevel(Gender.MALE, Level.A);
+ partyRepository.save(party);
+
+ PartyAddr filteredAddr = partyAddrRepository.save(PartyFixture.createPartyAddr("서울특별시", "송파구"));
+ filteredParty = PartyFixture.createParty("필터 모임", manager.getId(), filteredAddr);
+ ReflectionTestUtils.setField(filteredParty, "partyType", ParticipationType.SINGLE);
+ ReflectionTestUtils.setField(filteredParty, "activityTime", ActivityTime.AFTERNOON);
+ filteredParty = partyRepository.save(filteredParty);
+ filteredParty.addLevel(Gender.MALE, Level.B);
+ filteredParty = partyRepository.save(filteredParty);
+ memberPartyRepository.save(MemberFixture.createMemberParty(filteredParty, manager, Role.PARTY_MANAGER));
+
+ startDate = LocalDate.of(2026, 3, 23);
+ endDate = LocalDate.of(2026, 4, 5);
+
+ filteredEarlyExercise = saveRecommendableExercise(filteredParty, LocalDate.of(2026, 3, 25),
+ 37.51, 127.01, "필터 이른 체육관", LocalTime.of(9, 0), LocalTime.of(11, 0));
+ filteredPopularExercise = saveRecommendableExercise(filteredParty, LocalDate.of(2026, 3, 25),
+ 37.52, 127.02, "필터 인기 체육관", LocalTime.of(18, 0), LocalTime.of(20, 0));
+ memberExerciseRepository.save(MemberFixture.createMemberExercise(manager, filteredPopularExercise));
+ memberExerciseRepository.save(MemberFixture.createMemberExercise(subManager, filteredPopularExercise));
+ exerciseBookmarkRepository.save(ExerciseBookmark.builder()
+ .member(recommendationMember)
+ .exercise(filteredPopularExercise)
+ .build());
+ }
+
+ @Nested
+ @DisplayName("성공 케이스")
+ class Success {
+
+ @Test
+ @DisplayName("기본 요청은 기본 기간의 콕플 추천 캘린더를 거리순으로 반환한다")
+ void 기본_요청은_기본_기간의_콕플_추천_캘린더를_거리순으로_반환한다() throws Exception {
+ LocalDate expectedStart = ExerciseCalendarTestHelper.expectedDefaultStartDate();
+ LocalDate defaultExerciseDate = expectedStart.plusDays(9);
+ int weekIndex = ExerciseCalendarTestHelper.weekIndexFor(expectedStart, defaultExerciseDate);
+ int dayIndex = ExerciseCalendarTestHelper.dayIndexFor(defaultExerciseDate);
+
+ Exercise nearExercise = saveRecommendableExercise(party, defaultExerciseDate,
+ 37.5, 127.0, "가까운 체육관", LocalTime.of(11, 0), LocalTime.of(13, 0));
+ Exercise farExercise = saveRecommendableExercise(party, defaultExerciseDate,
+ 35.1, 129.1, "먼 체육관", LocalTime.of(9, 0), LocalTime.of(11, 0));
+
+ SecurityContextHelper.setAuthentication(recommendationMember.getId(), recommendationMember.getNickname());
+
+ mockMvc.perform(get("/api/exercises/recommendations/calendar"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.startDate").value(expectedStart.toString()))
+ .andExpect(jsonPath("$.data.endDate").value(ExerciseCalendarTestHelper.expectedDefaultEndDate().toString()))
+ .andExpect(jsonPath("$.data.weeks[" + weekIndex + "].days[" + dayIndex + "].date").value(defaultExerciseDate.toString()))
+ .andExpect(jsonPath("$.data.weeks[" + weekIndex + "].days[" + dayIndex + "].exercises[0].exerciseId").value(nearExercise.getId()))
+ .andExpect(jsonPath("$.data.weeks[" + weekIndex + "].days[" + dayIndex + "].exercises[0].buildingName").value("가까운 체육관"))
+ .andExpect(jsonPath("$.data.weeks[" + weekIndex + "].days[" + dayIndex + "].exercises[0].distance").value(0.0))
+ .andExpect(jsonPath("$.data.weeks[" + weekIndex + "].days[" + dayIndex + "].exercises[1].exerciseId").value(farExercise.getId()));
+ }
+
+ @Test
+ @DisplayName("필터 추천 최신순은 필터 조건에 맞는 운동만 시간순으로 반환한다")
+ void 필터_추천_최신순은_필터_조건에_맞는_운동만_시간순으로_반환한다() throws Exception {
+ SecurityContextHelper.setAuthentication(recommendationMember.getId(), recommendationMember.getNickname());
+
+ mockMvc.perform(get("/api/exercises/recommendations/calendar")
+ .param("startDate", startDate.toString())
+ .param("endDate", endDate.toString())
+ .param("isCockpleRecommend", "false")
+ .param("levels", "B")
+ .param("participationTypes", "SINGLE")
+ .param("activityTimes", "AFTERNOON")
+ .param("sortType", "LATEST"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.startDate").value("2026-03-23"))
+ .andExpect(jsonPath("$.data.endDate").value("2026-04-05"))
+ .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].exerciseId").value(filteredEarlyExercise.getId()))
+ .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].partyId").value(filteredParty.getId()))
+ .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].partyName").value("필터 모임"))
+ .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].isBookmarked").value(false))
+ .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].distance").value(nullValue()))
+ .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[1].exerciseId").value(filteredPopularExercise.getId()));
+ }
+
+ @Test
+ @DisplayName("필터 추천 인기순은 참가자 수가 많은 운동을 먼저 반환한다")
+ void 필터_추천_인기순은_참가자_수가_많은_운동을_먼저_반환한다() throws Exception {
+ SecurityContextHelper.setAuthentication(recommendationMember.getId(), recommendationMember.getNickname());
+
+ mockMvc.perform(get("/api/exercises/recommendations/calendar")
+ .param("startDate", startDate.toString())
+ .param("endDate", endDate.toString())
+ .param("isCockpleRecommend", "false")
+ .param("levels", "B")
+ .param("participationTypes", "SINGLE")
+ .param("activityTimes", "AFTERNOON")
+ .param("sortType", "POPULARITY"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].exerciseId").value(filteredPopularExercise.getId()))
+ .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].isBookmarked").value(true))
+ .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[1].exerciseId").value(filteredEarlyExercise.getId()));
+ }
+
+ @Test
+ @DisplayName("추천 운동이 없으면 기간 메타데이터와 빈 일자별 캘린더를 반환한다")
+ void 추천_운동이_없으면_기간_메타데이터와_빈_일자별_캘린더를_반환한다() throws Exception {
+ SecurityContextHelper.setAuthentication(recommendationMember.getId(), recommendationMember.getNickname());
+
+ mockMvc.perform(get("/api/exercises/recommendations/calendar")
+ .param("startDate", "2030-01-05")
+ .param("endDate", "2030-01-11"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.startDate").value("2030-01-05"))
+ .andExpect(jsonPath("$.data.endDate").value("2030-01-11"))
+ .andExpect(jsonPath("$.data.weeks[0].days[0].date").value("2029-12-31"))
+ .andExpect(jsonPath("$.data.weeks[0].days[5].date").value("2030-01-05"))
+ .andExpect(jsonPath("$.data.weeks[0].days[5].exercises").isEmpty());
+ }
+
+ @Test
+ @DisplayName("startDate만 주어져도 기본 기간이 적용된다")
+ void startDate만_주어져도_기본_기간이_적용된다() throws Exception {
+ LocalDate expectedStart = ExerciseCalendarTestHelper.expectedDefaultStartDate();
+ LocalDate defaultExerciseDate = expectedStart.plusDays(9);
+ int weekIndex = ExerciseCalendarTestHelper.weekIndexFor(expectedStart, defaultExerciseDate);
+ int dayIndex = ExerciseCalendarTestHelper.dayIndexFor(defaultExerciseDate);
+
+ Exercise defaultExercise = saveRecommendableExercise(party, defaultExerciseDate,
+ 37.5, 127.0, "기본기간 체육관", LocalTime.of(10, 0), LocalTime.of(12, 0));
+
+ SecurityContextHelper.setAuthentication(recommendationMember.getId(), recommendationMember.getNickname());
+
+ mockMvc.perform(get("/api/exercises/recommendations/calendar")
+ .param("startDate", "2026-03-25"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.startDate").value(expectedStart.toString()))
+ .andExpect(jsonPath("$.data.endDate").value(ExerciseCalendarTestHelper.expectedDefaultEndDate().toString()))
+ .andExpect(jsonPath("$.data.weeks[" + weekIndex + "].days[" + dayIndex + "].exercises[0].exerciseId").value(defaultExercise.getId()));
+ }
+
+ @Test
+ @DisplayName("종료일이 시작일보다 이전이어도 빈 캘린더를 반환한다")
+ void 종료일이_시작일보다_이전이어도_빈_캘린더를_반환한다() throws Exception {
+ SecurityContextHelper.setAuthentication(recommendationMember.getId(), recommendationMember.getNickname());
+
+ mockMvc.perform(get("/api/exercises/recommendations/calendar")
+ .param("startDate", "2026-04-05")
+ .param("endDate", "2026-03-23"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.startDate").value("2026-04-05"))
+ .andExpect(jsonPath("$.data.endDate").value("2026-03-23"))
+ .andExpect(jsonPath("$.data.weeks").isEmpty());
+ }
+ }
+
+ @Nested
+ @DisplayName("실패 케이스")
+ class Failure {
+
+ @Test
+ @DisplayName("존재하지 않는 멤버면 에러를 반환한다")
+ void 존재하지_않는_멤버면_에러를_반환한다() throws Exception {
+ SecurityContextHelper.setAuthentication(999L, "없는멤버");
+
+ mockMvc.perform(get("/api/exercises/recommendations/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()));
+ }
+
+ @Test
+ @DisplayName("대표 주소가 없으면 에러를 반환한다")
+ void 대표_주소가_없으면_에러를_반환한다() throws Exception {
+ SecurityContextHelper.setAuthentication(memberWithoutMainAddr.getId(), memberWithoutMainAddr.getNickname());
+
+ mockMvc.perform(get("/api/exercises/recommendations/calendar")
+ .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()));
+ }
+
+ @Test
+ @DisplayName("날짜 형식이 잘못되면 400을 반환한다")
+ void 날짜_형식이_잘못되면_400을_반환한다() throws Exception {
+ SecurityContextHelper.setAuthentication(recommendationMember.getId(), recommendationMember.getNickname());
+
+ mockMvc.perform(get("/api/exercises/recommendations/calendar")
+ .param("startDate", "invalid-date")
+ .param("endDate", endDate.toString()))
+ .andExpect(status().isBadRequest());
+ }
+ }
+
+ private Exercise saveRecommendableExercise(Party exerciseParty, LocalDate date,
+ double latitude, double longitude,
+ String buildingName, LocalTime startTime, LocalTime endTime) {
+ Exercise exercise = ExerciseFixture.createRecommendableExercise(
+ exerciseParty, date, latitude, longitude, buildingName);
+ ReflectionTestUtils.setField(exercise, "startTime", startTime);
+ ReflectionTestUtils.setField(exercise, "endTime", endTime);
+ return exerciseRepository.save(exercise);
+ }
+ }
+
+}
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
new file mode 100644
index 000000000..64e04dced
--- /dev/null
+++ b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseRecommendationIntegrationTest.java
@@ -0,0 +1,281 @@
+package umc.cockple.demo.domain.exercise.integration;
+
+import org.junit.jupiter.api.*;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.test.web.servlet.MockMvc;
+import umc.cockple.demo.domain.exercise.domain.Exercise;
+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.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.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.SecurityContextHelper;
+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 org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+class ExerciseRecommendationIntegrationTest extends IntegrationTestBase {
+
+ @Autowired MockMvc mockMvc;
+ @Autowired MemberRepository memberRepository;
+ @Autowired MemberAddrRepository memberAddrRepository;
+ @Autowired MemberPartyRepository memberPartyRepository;
+ @Autowired MemberExerciseRepository memberExerciseRepository;
+ @Autowired PartyRepository partyRepository;
+ @Autowired PartyAddrRepository partyAddrRepository;
+ @Autowired ExerciseRepository exerciseRepository;
+ @Autowired GuestRepository guestRepository;
+
+ // 조회 대상 회원 (모임 외부인, 추천 운동 수신 대상)
+ private Member outsider;
+ // 모임장 (모임 소속, 추천에서 제외)
+ private Member manager;
+ private Party party;
+
+ @BeforeEach
+ void setUp() {
+ // 추천 대상 회원: MALE, Level.A, 1995년생
+ outsider = memberRepository.save(
+ MemberFixture.createMember("외부회원", Gender.MALE, Level.A, 1001L, LocalDate.of(1995, 6, 15)));
+
+ // 대표 주소 저장 (서울 강남구, lat=37.5, lon=127.0)
+ MemberAddr addr = memberAddrRepository.save(MemberAddrFixture.createMainAddr(outsider));
+
+ // 모임장 (모임 소속)
+ manager = memberRepository.save(
+ MemberFixture.createMember("모임장", Gender.MALE, Level.A, 1002L, LocalDate.of(1995, 1, 1)));
+
+ // 모임 생성 (minBirthYear=1990, maxBirthYear=2005)
+ PartyAddr partyAddr = partyAddrRepository.save(PartyFixture.createPartyAddr("서울특별시", "강남구"));
+ party = partyRepository.save(PartyFixture.createParty("테스트 모임", manager.getId(), partyAddr));
+
+ // PartyLevel 추가: MALE A급 (cascade로 저장됨)
+ party.addLevel(Gender.MALE, Level.A);
+ partyRepository.save(party);
+
+ // 모임장을 모임 멤버로 등록
+ memberPartyRepository.save(MemberFixture.createMemberParty(party, manager, Role.PARTY_MANAGER));
+ }
+
+ @AfterEach
+ void tearDown() {
+ guestRepository.deleteAll();
+ memberExerciseRepository.deleteAll();
+ exerciseRepository.deleteAll();
+ memberPartyRepository.deleteAll();
+ partyRepository.deleteAll();
+ partyAddrRepository.deleteAll();
+ memberAddrRepository.deleteAll();
+ memberRepository.deleteAll();
+ SecurityContextHelper.clearAuthentication();
+ }
+
+ @Nested
+ @DisplayName("GET /api/exercises/recommendations - 사용자 추천 운동 조회")
+ class GetRecommendedExercises {
+
+ @Nested
+ @DisplayName("성공 케이스")
+ class Success {
+
+ @Test
+ @DisplayName("추천 운동이 존재하면 200 OK와 운동 목록을 반환한다")
+ void 추천_운동이_존재하면_목록을_반환한다() throws Exception {
+ // given
+ exerciseRepository.save(ExerciseFixture.createRecommendableExercise(party,
+ LocalDate.now().plusDays(3), 37.5, 127.0, "테스트 체육관"));
+
+ SecurityContextHelper.setAuthentication(outsider.getId(), outsider.getNickname());
+
+ // when & then
+ mockMvc.perform(get("/api/exercises/recommendations"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.totalExercises").value(1))
+ .andExpect(jsonPath("$.data.exercises").isArray())
+ .andExpect(jsonPath("$.data.exercises[0].partyName").value("테스트 모임"))
+ .andExpect(jsonPath("$.data.exercises[0].buildingName").value("테스트 체육관"))
+ .andExpect(jsonPath("$.data.exercises[0].isBookmarked").value(false));
+ }
+
+ @Test
+ @DisplayName("응답 필드가 모두 존재한다")
+ void 응답_필드가_모두_존재한다() throws Exception {
+ // given
+ Exercise saved = exerciseRepository.save(ExerciseFixture.createRecommendableExercise(party,
+ LocalDate.now().plusDays(3), 37.5, 127.0, "필드확인 체육관"));
+
+ SecurityContextHelper.setAuthentication(outsider.getId(), outsider.getNickname());
+
+ // when & then
+ mockMvc.perform(get("/api/exercises/recommendations"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.exercises[0].exerciseId").value(saved.getId()))
+ .andExpect(jsonPath("$.data.exercises[0].partyId").value(party.getId()))
+ .andExpect(jsonPath("$.data.exercises[0].partyName").exists())
+ .andExpect(jsonPath("$.data.exercises[0].date").exists())
+ .andExpect(jsonPath("$.data.exercises[0].dayOfWeek").exists())
+ .andExpect(jsonPath("$.data.exercises[0].startTime").exists())
+ .andExpect(jsonPath("$.data.exercises[0].buildingName").exists())
+ .andExpect(jsonPath("$.data.exercises[0].isBookmarked").exists());
+ }
+
+ @Test
+ @DisplayName("추천 운동이 없으면 빈 목록과 totalExercises 0을 반환한다")
+ void 추천_운동이_없으면_빈_목록을_반환한다() throws Exception {
+ // given - 운동 없음
+ SecurityContextHelper.setAuthentication(outsider.getId(), outsider.getNickname());
+
+ // when & then
+ mockMvc.perform(get("/api/exercises/recommendations"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.totalExercises").value(0))
+ .andExpect(jsonPath("$.data.exercises").isEmpty());
+ }
+
+ @Test
+ @DisplayName("이미 소속된 모임의 운동은 추천되지 않는다")
+ void 소속된_모임의_운동은_추천되지_않는다() throws Exception {
+ // given - outsider를 모임에 가입시킴
+ memberPartyRepository.save(
+ MemberFixture.createMemberParty(party, outsider, Role.PARTY_MEMBER));
+
+ exerciseRepository.save(ExerciseFixture.createRecommendableExercise(party,
+ LocalDate.now().plusDays(3), 37.5, 127.0, "테스트 체육관"));
+
+ SecurityContextHelper.setAuthentication(outsider.getId(), outsider.getNickname());
+
+ // when & then - 소속 모임이므로 추천 목록에서 제외
+ mockMvc.perform(get("/api/exercises/recommendations"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.totalExercises").value(0));
+ }
+
+ @Test
+ @DisplayName("outsideGuestAccept=false인 운동은 추천되지 않는다")
+ void outsideGuestAccept_false_운동은_추천되지_않는다() throws Exception {
+ // given - outsideGuestAccept=false 운동
+ exerciseRepository.save(
+ ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().plusDays(3)));
+ // createExerciseWithAddr는 outsideGuestAccept=false
+
+ SecurityContextHelper.setAuthentication(outsider.getId(), outsider.getNickname());
+
+ // when & then
+ mockMvc.perform(get("/api/exercises/recommendations"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.totalExercises").value(0));
+ }
+
+ @Test
+ @DisplayName("이미 지난 운동은 추천되지 않는다")
+ void 지난_운동은_추천되지_않는다() throws Exception {
+ // given - 과거 날짜 운동
+ exerciseRepository.save(ExerciseFixture.createRecommendableExercise(party,
+ LocalDate.now().minusDays(1), 37.5, 127.0, "과거 체육관"));
+
+ SecurityContextHelper.setAuthentication(outsider.getId(), outsider.getNickname());
+
+ // when & then
+ mockMvc.perform(get("/api/exercises/recommendations"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.totalExercises").value(0));
+ }
+
+ @Test
+ @DisplayName("이미 참여한 운동은 추천되지 않는다")
+ void 이미_참여한_운동은_추천되지_않는다() throws Exception {
+ // given
+ Exercise ex = exerciseRepository.save(ExerciseFixture.createRecommendableExercise(party,
+ LocalDate.now().plusDays(3), 37.5, 127.0, "참여완료 체육관"));
+ memberExerciseRepository.save(MemberFixture.createMemberExercise(outsider, ex));
+
+ SecurityContextHelper.setAuthentication(outsider.getId(), outsider.getNickname());
+
+ // when & then
+ mockMvc.perform(get("/api/exercises/recommendations"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.totalExercises").value(0));
+ }
+
+ @Test
+ @DisplayName("거리 가까운 순으로 정렬되어 반환된다")
+ void 거리_가까운_순으로_정렬된다() throws Exception {
+ // given
+ // 가까운 운동 (강남, lat=37.5 lon=127.0 - outsider 대표주소와 동일)
+ Exercise nearExercise = exerciseRepository.save(
+ ExerciseFixture.createRecommendableExercise(party,
+ LocalDate.now().plusDays(5), 37.5, 127.0, "가까운 체육관"));
+ // 먼 운동 (부산 해운대, lat=35.1 lon=129.1)
+ Exercise farExercise = exerciseRepository.save(
+ ExerciseFixture.createRecommendableExercise(party,
+ LocalDate.now().plusDays(1), 35.1, 129.1, "먼 체육관"));
+
+ SecurityContextHelper.setAuthentication(outsider.getId(), outsider.getNickname());
+
+ // when & then - 날짜가 늦어도 가까운 운동이 먼저
+ mockMvc.perform(get("/api/exercises/recommendations"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.totalExercises").value(2))
+ .andExpect(jsonPath("$.data.exercises[0].exerciseId").value(nearExercise.getId()))
+ .andExpect(jsonPath("$.data.exercises[1].exerciseId").value(farExercise.getId()));
+ }
+
+ @Test
+ @DisplayName("최대 10개까지만 반환된다")
+ void 최대_10개까지만_반환된다() throws Exception {
+ // given - 12개 운동 저장
+ for (int i = 1; i <= 12; i++) {
+ exerciseRepository.save(ExerciseFixture.createRecommendableExercise(party,
+ LocalDate.now().plusDays(i), 37.5, 127.0, "체육관" + i));
+ }
+
+ SecurityContextHelper.setAuthentication(outsider.getId(), outsider.getNickname());
+
+ // when & then
+ mockMvc.perform(get("/api/exercises/recommendations"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.totalExercises").value(10))
+ .andExpect(jsonPath("$.data.exercises.length()").value(10));
+ }
+ }
+
+ @Nested
+ @DisplayName("실패 케이스")
+ class Failure {
+
+ @Test
+ @DisplayName("대표 주소가 없으면 400 에러를 반환한다")
+ void 대표_주소가_없으면_400_에러를_반환한다() throws Exception {
+ // given - 대표 주소 없는 회원
+ Member noAddrMember = memberRepository.save(
+ MemberFixture.createMember("주소없는회원", Gender.MALE, Level.A, 9999L,
+ LocalDate.of(1995, 1, 1)));
+ // MemberAddr 저장 안 함
+
+ SecurityContextHelper.setAuthentication(noAddrMember.getId(), noAddrMember.getNickname());
+
+ // when & then
+ mockMvc.perform(get("/api/exercises/recommendations"))
+ .andExpect(status().isBadRequest());
+ }
+ }
+ }
+}
diff --git a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseCommandServiceTest.java b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseCommandServiceTest.java
index 506d55746..6d93406a6 100644
--- a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseCommandServiceTest.java
+++ b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseCommandServiceTest.java
@@ -10,9 +10,12 @@
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import umc.cockple.demo.domain.exercise.domain.Exercise;
+import umc.cockple.demo.domain.exercise.domain.Guest;
import umc.cockple.demo.domain.exercise.dto.ExerciseCancelDTO;
import umc.cockple.demo.domain.exercise.dto.ExerciseCreateDTO;
import umc.cockple.demo.domain.exercise.dto.ExerciseDeleteDTO;
+import umc.cockple.demo.domain.exercise.dto.ExerciseGuestInviteDTO;
+import umc.cockple.demo.domain.exercise.dto.ExerciseJoinDTO;
import umc.cockple.demo.domain.exercise.dto.ExerciseUpdateDTO;
import umc.cockple.demo.domain.exercise.exception.ExerciseErrorCode;
import umc.cockple.demo.domain.exercise.exception.ExerciseException;
@@ -28,6 +31,8 @@
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.GuestFixture;
import umc.cockple.demo.support.fixture.MemberFixture;
import umc.cockple.demo.support.fixture.PartyFixture;
@@ -156,14 +161,8 @@ class DeleteExercise {
@BeforeEach
void setUp() {
- exercise = Exercise.builder()
- .date(LocalDate.of(2099, 12, 31))
- .startTime(LocalTime.of(10, 0))
- .endTime(LocalTime.of(12, 0))
- .maxCapacity(10)
- .partyGuestAccept(true)
- .outsideGuestAccept(false)
- .build();
+ exercise = ExerciseFixture.createExercise(party, LocalDate.of(2099, 12, 31),
+ LocalTime.of(12, 0), true, false);
ReflectionTestUtils.setField(exercise, "id", 100L);
}
@@ -233,14 +232,8 @@ class UpdateExercise {
@BeforeEach
void setUp() {
- exercise = Exercise.builder()
- .date(LocalDate.of(2099, 12, 31))
- .startTime(LocalTime.of(10, 0))
- .endTime(LocalTime.of(12, 0))
- .maxCapacity(10)
- .partyGuestAccept(true)
- .outsideGuestAccept(false)
- .build();
+ exercise = ExerciseFixture.createExercise(party, LocalDate.of(2099, 12, 31),
+ LocalTime.of(12, 0), true, false);
ReflectionTestUtils.setField(exercise, "id", 100L);
request = new ExerciseUpdateDTO.Request(
@@ -313,6 +306,150 @@ void memberNotFound_throwsException() {
}
}
+ @Nested
+ @DisplayName("joinExercise")
+ class JoinExercise {
+
+ private Exercise exercise;
+
+ @BeforeEach
+ void setUp() {
+ exercise = ExerciseFixture.createExercise(party, LocalDate.of(2099, 12, 31),
+ LocalTime.of(12, 0), true, false);
+ ReflectionTestUtils.setField(exercise, "id", 100L);
+ }
+
+ @Nested
+ @DisplayName("성공 케이스")
+ class Success {
+
+ @Test
+ @DisplayName("Exercise(WithPartyLevels), Member 조회 후 ExerciseParticipationService에 위임한다")
+ void delegatesToParticipationService() {
+ // given
+ ExerciseJoinDTO.Response expectedResponse = ExerciseJoinDTO.Response.builder()
+ .participantId(50L)
+ .currentParticipants(1)
+ .build();
+
+ given(exerciseRepository.findByIdWithPartyLevels(exercise.getId())).willReturn(Optional.of(exercise));
+ given(memberRepository.findById(manager.getId())).willReturn(Optional.of(manager));
+ given(exerciseParticipationService.joinExercise(exercise, manager)).willReturn(expectedResponse);
+
+ // when
+ ExerciseJoinDTO.Response response = exerciseCommandService.joinExercise(
+ exercise.getId(), manager.getId());
+
+ // then
+ assertThat(response.participantId()).isEqualTo(50L);
+ assertThat(response.currentParticipants()).isEqualTo(1);
+ then(exerciseParticipationService).should().joinExercise(exercise, manager);
+ }
+ }
+
+ @Nested
+ @DisplayName("실패 케이스")
+ class Failure {
+
+ @Test
+ @DisplayName("존재하지 않는 운동이면 ExerciseException(EXERCISE_NOT_FOUND)을 던진다")
+ void exerciseNotFound_throwsException() {
+ given(exerciseRepository.findByIdWithPartyLevels(999L)).willReturn(Optional.empty());
+
+ assertThatThrownBy(() ->
+ exerciseCommandService.joinExercise(999L, manager.getId()))
+ .isInstanceOf(ExerciseException.class)
+ .satisfies(e -> assertThat(((ExerciseException) e).getCode())
+ .isEqualTo(ExerciseErrorCode.EXERCISE_NOT_FOUND));
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 멤버면 ExerciseException(MEMBER_NOT_FOUND)을 던진다")
+ void memberNotFound_throwsException() {
+ given(exerciseRepository.findByIdWithPartyLevels(exercise.getId())).willReturn(Optional.of(exercise));
+ given(memberRepository.findById(999L)).willReturn(Optional.empty());
+
+ assertThatThrownBy(() ->
+ exerciseCommandService.joinExercise(exercise.getId(), 999L))
+ .isInstanceOf(ExerciseException.class)
+ .satisfies(e -> assertThat(((ExerciseException) e).getCode())
+ .isEqualTo(ExerciseErrorCode.MEMBER_NOT_FOUND));
+ }
+ }
+ }
+
+ @Nested
+ @DisplayName("cancelParticipation")
+ class CancelParticipation {
+
+ private Exercise exercise;
+
+ @BeforeEach
+ void setUp() {
+ exercise = ExerciseFixture.createExercise(party, LocalDate.of(2099, 12, 31),
+ LocalTime.of(12, 0), true, false);
+ ReflectionTestUtils.setField(exercise, "id", 100L);
+ }
+
+ @Nested
+ @DisplayName("성공 케이스")
+ class Success {
+
+ @Test
+ @DisplayName("Exercise, Member 조회 후 ExerciseParticipationService에 위임한다")
+ void delegatesToParticipationService() {
+ // given
+ ExerciseCancelDTO.Response expectedResponse = ExerciseCancelDTO.Response.builder()
+ .memberName("모임장")
+ .currentParticipants(0)
+ .build();
+
+ given(exerciseRepository.findById(exercise.getId())).willReturn(Optional.of(exercise));
+ given(memberRepository.findById(manager.getId())).willReturn(Optional.of(manager));
+ given(exerciseParticipationService.cancelParticipation(exercise, manager)).willReturn(expectedResponse);
+
+ // when
+ ExerciseCancelDTO.Response response = exerciseCommandService.cancelParticipation(
+ exercise.getId(), manager.getId());
+
+ // then
+ assertThat(response.memberName()).isEqualTo("모임장");
+ assertThat(response.currentParticipants()).isEqualTo(0);
+ then(exerciseParticipationService).should().cancelParticipation(exercise, manager);
+ }
+ }
+
+ @Nested
+ @DisplayName("실패 케이스")
+ class Failure {
+
+ @Test
+ @DisplayName("존재하지 않는 운동이면 ExerciseException(EXERCISE_NOT_FOUND)을 던진다")
+ void exerciseNotFound_throwsException() {
+ given(exerciseRepository.findById(999L)).willReturn(Optional.empty());
+
+ assertThatThrownBy(() ->
+ exerciseCommandService.cancelParticipation(999L, manager.getId()))
+ .isInstanceOf(ExerciseException.class)
+ .satisfies(e -> assertThat(((ExerciseException) e).getCode())
+ .isEqualTo(ExerciseErrorCode.EXERCISE_NOT_FOUND));
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 멤버면 ExerciseException(MEMBER_NOT_FOUND)을 던진다")
+ void memberNotFound_throwsException() {
+ given(exerciseRepository.findById(exercise.getId())).willReturn(Optional.of(exercise));
+ given(memberRepository.findById(999L)).willReturn(Optional.empty());
+
+ assertThatThrownBy(() ->
+ exerciseCommandService.cancelParticipation(exercise.getId(), 999L))
+ .isInstanceOf(ExerciseException.class)
+ .satisfies(e -> assertThat(((ExerciseException) e).getCode())
+ .isEqualTo(ExerciseErrorCode.MEMBER_NOT_FOUND));
+ }
+ }
+ }
+
@Nested
@DisplayName("cancelParticipationByManager")
class CancelParticipationByManager {
@@ -321,14 +458,8 @@ class CancelParticipationByManager {
@BeforeEach
void setUp() {
- exercise = Exercise.builder()
- .date(LocalDate.of(2099, 12, 31))
- .startTime(LocalTime.of(10, 0))
- .endTime(LocalTime.of(12, 0))
- .maxCapacity(10)
- .partyGuestAccept(true)
- .outsideGuestAccept(false)
- .build();
+ exercise = ExerciseFixture.createExercise(party, LocalDate.of(2099, 12, 31),
+ LocalTime.of(12, 0), true, false);
ReflectionTestUtils.setField(exercise, "id", 100L);
}
@@ -396,4 +527,171 @@ void managerNotFound_throwsException() {
}
}
}
+
+ @Nested
+ @DisplayName("inviteGuest")
+ class InviteGuest {
+
+ private Exercise exercise;
+ private ExerciseGuestInviteDTO.Request request;
+
+ @BeforeEach
+ void setUp() {
+ exercise = ExerciseFixture.createExercise(party, LocalDate.of(2099, 12, 31),
+ LocalTime.of(12, 0), true, false);
+ ReflectionTestUtils.setField(exercise, "id", 100L);
+
+ request = new ExerciseGuestInviteDTO.Request("테스트게스트", "남성", "B조");
+ }
+
+ @Nested
+ @DisplayName("성공 케이스")
+ class Success {
+
+ @Test
+ @DisplayName("Exercise, Member 조회 후 ExerciseGuestService에 위임한다")
+ void delegatesToGuestService() {
+ // given
+ ExerciseGuestInviteDTO.Response expectedResponse = ExerciseGuestInviteDTO.Response.builder()
+ .guestId(200L)
+ .currentParticipants(1)
+ .build();
+
+ given(exerciseRepository.findById(exercise.getId())).willReturn(Optional.of(exercise));
+ given(memberRepository.findById(manager.getId())).willReturn(Optional.of(manager));
+ given(exerciseGuestService.inviteGuest(exercise, manager, request)).willReturn(expectedResponse);
+
+ // when
+ ExerciseGuestInviteDTO.Response response = exerciseCommandService.inviteGuest(
+ exercise.getId(), manager.getId(), request);
+
+ // then
+ assertThat(response.guestId()).isEqualTo(200L);
+ assertThat(response.currentParticipants()).isEqualTo(1);
+ then(exerciseGuestService).should().inviteGuest(exercise, manager, request);
+ }
+ }
+
+ @Nested
+ @DisplayName("실패 케이스")
+ class Failure {
+
+ @Test
+ @DisplayName("존재하지 않는 운동이면 ExerciseException(EXERCISE_NOT_FOUND)을 던진다")
+ void exerciseNotFound_throwsException() {
+ given(exerciseRepository.findById(999L)).willReturn(Optional.empty());
+
+ assertThatThrownBy(() ->
+ exerciseCommandService.inviteGuest(999L, manager.getId(), request))
+ .isInstanceOf(ExerciseException.class)
+ .satisfies(e -> assertThat(((ExerciseException) e).getCode())
+ .isEqualTo(ExerciseErrorCode.EXERCISE_NOT_FOUND));
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 멤버면 ExerciseException(MEMBER_NOT_FOUND)을 던진다")
+ void memberNotFound_throwsException() {
+ given(exerciseRepository.findById(exercise.getId())).willReturn(Optional.of(exercise));
+ given(memberRepository.findById(999L)).willReturn(Optional.empty());
+
+ assertThatThrownBy(() ->
+ exerciseCommandService.inviteGuest(exercise.getId(), 999L, request))
+ .isInstanceOf(ExerciseException.class)
+ .satisfies(e -> assertThat(((ExerciseException) e).getCode())
+ .isEqualTo(ExerciseErrorCode.MEMBER_NOT_FOUND));
+ }
+ }
+ }
+
+ @Nested
+ @DisplayName("cancelGuestInvitation")
+ class CancelGuestInvitation {
+
+ private Exercise exercise;
+ private Guest guest;
+
+ @BeforeEach
+ void setUp() {
+ exercise = ExerciseFixture.createExercise(party, LocalDate.of(2099, 12, 31),
+ LocalTime.of(12, 0), true, false);
+ ReflectionTestUtils.setField(exercise, "id", 100L);
+
+ guest = GuestFixture.createGuest(exercise, manager.getId());
+ ReflectionTestUtils.setField(guest, "id", 60L);
+ }
+
+ @Nested
+ @DisplayName("성공 케이스")
+ class Success {
+
+ @Test
+ @DisplayName("Exercise, Member, Guest 조회 후 ExerciseGuestService에 위임한다")
+ void delegatesToGuestService() {
+ // given
+ ExerciseCancelDTO.Response expectedResponse = ExerciseCancelDTO.Response.builder()
+ .memberName("게스트")
+ .currentParticipants(0)
+ .build();
+
+ given(exerciseRepository.findById(exercise.getId())).willReturn(Optional.of(exercise));
+ given(memberRepository.findById(manager.getId())).willReturn(Optional.of(manager));
+ given(guestRepository.findById(guest.getId())).willReturn(Optional.of(guest));
+ given(exerciseGuestService.cancelGuestInvitation(exercise, guest, manager))
+ .willReturn(expectedResponse);
+
+ // when
+ ExerciseCancelDTO.Response response = exerciseCommandService.cancelGuestInvitation(
+ exercise.getId(), guest.getId(), manager.getId());
+
+ // then
+ assertThat(response.memberName()).isEqualTo("게스트");
+ assertThat(response.currentParticipants()).isEqualTo(0);
+ then(exerciseGuestService).should().cancelGuestInvitation(exercise, guest, manager);
+ }
+ }
+
+ @Nested
+ @DisplayName("실패 케이스")
+ class Failure {
+
+ @Test
+ @DisplayName("존재하지 않는 운동이면 ExerciseException(EXERCISE_NOT_FOUND)을 던진다")
+ void exerciseNotFound_throwsException() {
+ given(exerciseRepository.findById(999L)).willReturn(Optional.empty());
+
+ assertThatThrownBy(() ->
+ exerciseCommandService.cancelGuestInvitation(999L, guest.getId(), manager.getId()))
+ .isInstanceOf(ExerciseException.class)
+ .satisfies(e -> assertThat(((ExerciseException) e).getCode())
+ .isEqualTo(ExerciseErrorCode.EXERCISE_NOT_FOUND));
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 멤버면 ExerciseException(MEMBER_NOT_FOUND)을 던진다")
+ void memberNotFound_throwsException() {
+ given(exerciseRepository.findById(exercise.getId())).willReturn(Optional.of(exercise));
+ given(memberRepository.findById(999L)).willReturn(Optional.empty());
+
+ assertThatThrownBy(() ->
+ exerciseCommandService.cancelGuestInvitation(exercise.getId(), guest.getId(), 999L))
+ .isInstanceOf(ExerciseException.class)
+ .satisfies(e -> assertThat(((ExerciseException) e).getCode())
+ .isEqualTo(ExerciseErrorCode.MEMBER_NOT_FOUND));
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 게스트면 ExerciseException(GUEST_NOT_FOUND)을 던진다")
+ void guestNotFound_throwsException() {
+ given(exerciseRepository.findById(exercise.getId())).willReturn(Optional.of(exercise));
+ given(memberRepository.findById(manager.getId())).willReturn(Optional.of(manager));
+ given(guestRepository.findById(999L)).willReturn(Optional.empty());
+
+ assertThatThrownBy(() ->
+ exerciseCommandService.cancelGuestInvitation(exercise.getId(), 999L, manager.getId()))
+ .isInstanceOf(ExerciseException.class)
+ .satisfies(e -> assertThat(((ExerciseException) e).getCode())
+ .isEqualTo(ExerciseErrorCode.GUEST_NOT_FOUND));
+ }
+ }
+ }
}
\ No newline at end of file
diff --git a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseGuestServiceTest.java b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseGuestServiceTest.java
new file mode 100644
index 000000000..23034e613
--- /dev/null
+++ b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseGuestServiceTest.java
@@ -0,0 +1,249 @@
+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.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.ExerciseCancelDTO;
+import umc.cockple.demo.domain.exercise.dto.ExerciseGuestInviteDTO;
+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.command.internal.ExerciseGuestService;
+import umc.cockple.demo.domain.file.service.FileService;
+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.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.GuestFixture;
+import umc.cockple.demo.support.fixture.MemberFixture;
+import umc.cockple.demo.support.fixture.PartyFixture;
+
+import java.time.LocalDate;
+import java.time.LocalTime;
+
+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.BDDMockito.then;
+
+@ExtendWith(MockitoExtension.class)
+@DisplayName("ExerciseGuestService")
+class ExerciseGuestServiceTest {
+
+ @Mock private ExerciseRepository exerciseRepository;
+ @Mock private GuestRepository guestRepository;
+ @Mock private MemberPartyRepository memberPartyRepository;
+ @Mock private MemberExerciseRepository memberExerciseRepository;
+ @Mock private FileService fileService;
+
+ private ExerciseGuestService exerciseGuestService;
+
+ private Member manager;
+ private Party party;
+ private Exercise exercise;
+
+ @BeforeEach
+ void setUp() {
+ ExerciseValidator exerciseValidator = new ExerciseValidator(memberPartyRepository, memberExerciseRepository);
+ ExerciseConverter exerciseConverter = new ExerciseConverter(fileService);
+ exerciseGuestService = new ExerciseGuestService(
+ exerciseRepository, guestRepository, exerciseValidator, 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.of(2099, 12, 31),
+ LocalTime.of(12, 0), true, false);
+ ReflectionTestUtils.setField(exercise, "id", 100L);
+ }
+
+ @Nested
+ @DisplayName("inviteGuest")
+ class InviteGuest {
+
+ @Nested
+ @DisplayName("성공 케이스")
+ class Success {
+
+ @Test
+ @DisplayName("파티 멤버가 게스트를 초대하면 Response를 반환한다")
+ void partyMember_inviteGuest_success() {
+ // given
+ ExerciseGuestInviteDTO.Request request = new ExerciseGuestInviteDTO.Request(
+ "테스트게스트", "남성", "B조");
+
+ given(memberPartyRepository.existsByPartyAndMember(party, manager)).willReturn(true);
+ given(guestRepository.save(any(Guest.class)))
+ .willAnswer(invocation -> {
+ Guest g = invocation.getArgument(0);
+ ReflectionTestUtils.setField(g, "id", 200L);
+ return g;
+ });
+
+ // when
+ ExerciseGuestInviteDTO.Response response = exerciseGuestService.inviteGuest(
+ exercise, manager, request);
+
+ // then
+ assertThat(response.guestId()).isEqualTo(200L);
+ assertThat(response.currentParticipants()).isNotNull();
+ }
+ }
+
+ @Nested
+ @DisplayName("실패 케이스")
+ class Failure {
+
+ @Test
+ @DisplayName("이미 시작된 운동이면 ExerciseException(EXERCISE_ALREADY_STARTED_INVITATION)을 던진다")
+ void alreadyStarted_throwsException() {
+ Exercise startedExercise = ExerciseFixture.createExercise(party, LocalDate.of(2000, 1, 1),
+ LocalTime.of(12, 0), true, false);
+ ReflectionTestUtils.setField(startedExercise, "id", 200L);
+
+ ExerciseGuestInviteDTO.Request request = new ExerciseGuestInviteDTO.Request(
+ "테스트게스트", "남성", "B조");
+
+ assertThatThrownBy(() ->
+ exerciseGuestService.inviteGuest(startedExercise, manager, request))
+ .isInstanceOf(ExerciseException.class)
+ .satisfies(e -> assertThat(((ExerciseException) e).getCode())
+ .isEqualTo(ExerciseErrorCode.EXERCISE_ALREADY_STARTED_INVITATION));
+ }
+
+ @Test
+ @DisplayName("파티 멤버가 아닌 사람이 초대하면 ExerciseException(NOT_PARTY_MEMBER_FOR_GUEST_INVITE)을 던진다")
+ void notPartyMember_throwsException() {
+ Member outsider = MemberFixture.createMember("외부인", Gender.MALE, Level.B, 3001L);
+ ReflectionTestUtils.setField(outsider, "id", 3L);
+
+ ExerciseGuestInviteDTO.Request request = new ExerciseGuestInviteDTO.Request(
+ "테스트게스트", "남성", "B조");
+
+ given(memberPartyRepository.existsByPartyAndMember(party, outsider)).willReturn(false);
+
+ assertThatThrownBy(() ->
+ exerciseGuestService.inviteGuest(exercise, outsider, request))
+ .isInstanceOf(ExerciseException.class)
+ .satisfies(e -> assertThat(((ExerciseException) e).getCode())
+ .isEqualTo(ExerciseErrorCode.NOT_PARTY_MEMBER_FOR_GUEST_INVITE));
+ }
+
+ @Test
+ @DisplayName("게스트 초대 정책 비허용이면 ExerciseException(GUEST_INVITATION_NOT_ALLOWED)을 던진다")
+ void guestPolicyNotAllowed_throwsException() {
+ Exercise noGuestExercise = ExerciseFixture.createExercise(party, LocalDate.of(2099, 12, 31),
+ LocalTime.of(12, 0), false, false);
+ ReflectionTestUtils.setField(noGuestExercise, "id", 201L);
+
+ ExerciseGuestInviteDTO.Request request = new ExerciseGuestInviteDTO.Request(
+ "테스트게스트", "남성", "B조");
+
+ given(memberPartyRepository.existsByPartyAndMember(party, manager)).willReturn(true);
+
+ assertThatThrownBy(() ->
+ exerciseGuestService.inviteGuest(noGuestExercise, manager, request))
+ .isInstanceOf(ExerciseException.class)
+ .satisfies(e -> assertThat(((ExerciseException) e).getCode())
+ .isEqualTo(ExerciseErrorCode.GUEST_INVITATION_NOT_ALLOWED));
+ }
+ }
+ }
+
+ @Nested
+ @DisplayName("cancelGuestInvitation")
+ class CancelGuestInvitation {
+
+ @Nested
+ @DisplayName("성공 케이스")
+ class Success {
+
+ @Test
+ @DisplayName("초대자가 본인 게스트를 취소하면 Response를 반환한다")
+ void cancelGuestInvitation_success() {
+ // given
+ Guest guest = GuestFixture.createGuest(exercise, manager.getId());
+ ReflectionTestUtils.setField(guest, "id", 60L);
+
+ // when
+ ExerciseCancelDTO.Response response = exerciseGuestService
+ .cancelGuestInvitation(exercise, guest, manager);
+
+ // then
+ assertThat(response.memberName()).isEqualTo("게스트");
+ assertThat(response.currentParticipants()).isNotNull();
+ then(guestRepository).should().delete(guest);
+ then(exerciseRepository).should().save(exercise);
+ }
+ }
+
+ @Nested
+ @DisplayName("실패 케이스")
+ class Failure {
+
+ @Test
+ @DisplayName("이미 시작된 운동이면 ExerciseException(EXERCISE_ALREADY_STARTED_CANCEL)을 던진다")
+ void alreadyStarted_throwsException() {
+ Exercise startedExercise = ExerciseFixture.createExercise(party, LocalDate.of(2000, 1, 1),
+ LocalTime.of(12, 0), true, false);
+ ReflectionTestUtils.setField(startedExercise, "id", 200L);
+
+ Guest guest = GuestFixture.createGuest(startedExercise, manager.getId());
+ ReflectionTestUtils.setField(guest, "id", 60L);
+
+ assertThatThrownBy(() ->
+ exerciseGuestService.cancelGuestInvitation(startedExercise, guest, manager))
+ .isInstanceOf(ExerciseException.class)
+ .satisfies(e -> assertThat(((ExerciseException) e).getCode())
+ .isEqualTo(ExerciseErrorCode.EXERCISE_ALREADY_STARTED_CANCEL));
+ }
+
+ @Test
+ @DisplayName("게스트가 해당 운동에 속하지 않으면 ExerciseException(GUEST_IS_NOT_PARTICIPATED_IN_EXERCISE)을 던진다")
+ void guestNotInExercise_throwsException() {
+ Exercise otherExercise = ExerciseFixture.createExercise(party, LocalDate.of(2099, 12, 31),
+ LocalTime.of(12, 0), true, false);
+ ReflectionTestUtils.setField(otherExercise, "id", 201L);
+
+ Guest guest = GuestFixture.createGuest(otherExercise, manager.getId());
+ ReflectionTestUtils.setField(guest, "id", 60L);
+
+ assertThatThrownBy(() ->
+ exerciseGuestService.cancelGuestInvitation(exercise, guest, manager))
+ .isInstanceOf(ExerciseException.class)
+ .satisfies(e -> assertThat(((ExerciseException) e).getCode())
+ .isEqualTo(ExerciseErrorCode.GUEST_IS_NOT_PARTICIPATED_IN_EXERCISE));
+ }
+
+ @Test
+ @DisplayName("본인이 초대하지 않은 게스트면 ExerciseException(GUEST_NOT_INVITED_BY_MEMBER)을 던진다")
+ void guestNotInvitedByMember_throwsException() {
+ Guest guest = GuestFixture.createGuest(exercise, 999L);
+ ReflectionTestUtils.setField(guest, "id", 60L);
+
+ assertThatThrownBy(() ->
+ exerciseGuestService.cancelGuestInvitation(exercise, guest, manager))
+ .isInstanceOf(ExerciseException.class)
+ .satisfies(e -> assertThat(((ExerciseException) e).getCode())
+ .isEqualTo(ExerciseErrorCode.GUEST_NOT_INVITED_BY_MEMBER));
+ }
+ }
+ }
+}
diff --git a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseLifecycleServiceTest.java b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseLifecycleServiceTest.java
index 5a98b3a02..c317929bd 100644
--- a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseLifecycleServiceTest.java
+++ b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseLifecycleServiceTest.java
@@ -17,7 +17,7 @@
import umc.cockple.demo.domain.exercise.exception.ExerciseException;
import umc.cockple.demo.domain.exercise.repository.ExerciseRepository;
import umc.cockple.demo.domain.exercise.service.command.internal.ExerciseLifecycleService;
-import umc.cockple.demo.domain.image.service.ImageService;
+import umc.cockple.demo.domain.file.service.FileService;
import umc.cockple.demo.domain.member.domain.Member;
import umc.cockple.demo.domain.member.repository.MemberExerciseRepository;
import umc.cockple.demo.domain.member.repository.MemberPartyRepository;
@@ -49,7 +49,7 @@ class ExerciseLifecycleServiceTest {
@Mock private PartyRepository partyRepository;
@Mock private MemberPartyRepository memberPartyRepository;
@Mock private MemberExerciseRepository memberExerciseRepository;
- @Mock private ImageService imageService;
+ @Mock private FileService fileService;
private ExerciseLifecycleService exerciseLifecycleService;
@@ -59,7 +59,7 @@ class ExerciseLifecycleServiceTest {
@BeforeEach
void setUp() {
ExerciseValidator exerciseValidator = new ExerciseValidator(memberPartyRepository, memberExerciseRepository);
- ExerciseConverter exerciseConverter = new ExerciseConverter(imageService);
+ ExerciseConverter exerciseConverter = new ExerciseConverter(fileService);
exerciseLifecycleService = new ExerciseLifecycleService(
exerciseRepository, partyRepository, exerciseValidator, exerciseConverter);
@@ -128,9 +128,9 @@ void subManagerCreatesExercise_success() {
Member subManager = MemberFixture.createMember("부모임장", Gender.FEMALE, Level.B, 1002L);
ReflectionTestUtils.setField(subManager, "id", 2L);
- given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), subManager.getId(), Role.party_MANAGER))
+ given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), subManager.getId(), Role.PARTY_MANAGER))
.willReturn(false);
- given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), subManager.getId(), Role.party_SUBMANAGER))
+ given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), subManager.getId(), Role.PARTY_SUBMANAGER))
.willReturn(true);
Exercise savedExercise = Exercise.builder()
@@ -175,9 +175,9 @@ void normalMember_throwsException() {
Member normalMember = MemberFixture.createMember("일반멤버", Gender.FEMALE, Level.B, 1002L);
ReflectionTestUtils.setField(normalMember, "id", 2L);
- given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), normalMember.getId(), Role.party_MANAGER))
+ given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), normalMember.getId(), Role.PARTY_MANAGER))
.willReturn(false);
- given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), normalMember.getId(), Role.party_SUBMANAGER))
+ given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), normalMember.getId(), Role.PARTY_SUBMANAGER))
.willReturn(false);
assertThatThrownBy(() ->
@@ -278,9 +278,9 @@ void subManagerDeletesExercise_success() {
Member subManager = MemberFixture.createMember("부모임장", Gender.FEMALE, Level.B, 1002L);
ReflectionTestUtils.setField(subManager, "id", 2L);
- given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), subManager.getId(), Role.party_MANAGER))
+ given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), subManager.getId(), Role.PARTY_MANAGER))
.willReturn(false);
- given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), subManager.getId(), Role.party_SUBMANAGER))
+ given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), subManager.getId(), Role.PARTY_SUBMANAGER))
.willReturn(true);
// when
@@ -303,9 +303,9 @@ void normalMember_throwsException() {
Member normalMember = MemberFixture.createMember("일반멤버", Gender.FEMALE, Level.B, 1002L);
ReflectionTestUtils.setField(normalMember, "id", 2L);
- given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), normalMember.getId(), Role.party_MANAGER))
+ given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), normalMember.getId(), Role.PARTY_MANAGER))
.willReturn(false);
- given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), normalMember.getId(), Role.party_SUBMANAGER))
+ given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), normalMember.getId(), Role.PARTY_SUBMANAGER))
.willReturn(false);
assertThatThrownBy(() ->
@@ -384,9 +384,9 @@ void subManagerUpdatesExercise_success() {
Member subManager = MemberFixture.createMember("부모임장", Gender.FEMALE, Level.B, 1002L);
ReflectionTestUtils.setField(subManager, "id", 2L);
- given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), subManager.getId(), Role.party_MANAGER))
+ given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), subManager.getId(), Role.PARTY_MANAGER))
.willReturn(false);
- given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), subManager.getId(), Role.party_SUBMANAGER))
+ given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), subManager.getId(), Role.PARTY_SUBMANAGER))
.willReturn(true);
Exercise savedExercise = Exercise.builder()
@@ -418,9 +418,9 @@ void normalMember_throwsException() {
Member normalMember = MemberFixture.createMember("일반멤버", Gender.FEMALE, Level.B, 1002L);
ReflectionTestUtils.setField(normalMember, "id", 2L);
- given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), normalMember.getId(), Role.party_MANAGER))
+ given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), normalMember.getId(), Role.PARTY_MANAGER))
.willReturn(false);
- given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), normalMember.getId(), Role.party_SUBMANAGER))
+ given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), normalMember.getId(), Role.PARTY_SUBMANAGER))
.willReturn(false);
assertThatThrownBy(() ->
diff --git a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseParticipationServiceTest.java b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseParticipationServiceTest.java
index d3af78261..62be82d77 100644
--- a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseParticipationServiceTest.java
+++ b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseParticipationServiceTest.java
@@ -12,12 +12,13 @@
import umc.cockple.demo.domain.exercise.domain.Exercise;
import umc.cockple.demo.domain.exercise.domain.Guest;
import umc.cockple.demo.domain.exercise.dto.ExerciseCancelDTO;
+import umc.cockple.demo.domain.exercise.dto.ExerciseJoinDTO;
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.command.internal.ExerciseParticipationService;
-import umc.cockple.demo.domain.image.service.ImageService;
+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.repository.MemberExerciseRepository;
@@ -27,6 +28,7 @@
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.fixture.ExerciseFixture;
import umc.cockple.demo.support.fixture.GuestFixture;
import umc.cockple.demo.support.fixture.MemberFixture;
import umc.cockple.demo.support.fixture.PartyFixture;
@@ -37,6 +39,7 @@
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.BDDMockito.then;
@@ -49,7 +52,7 @@ class ExerciseParticipationServiceTest {
@Mock private MemberPartyRepository memberPartyRepository;
@Mock private MemberExerciseRepository memberExerciseRepository;
@Mock private GuestRepository guestRepository;
- @Mock private ImageService imageService;
+ @Mock private FileService fileService;
private ExerciseParticipationService exerciseParticipationService;
@@ -60,7 +63,7 @@ class ExerciseParticipationServiceTest {
@BeforeEach
void setUp() {
ExerciseValidator exerciseValidator = new ExerciseValidator(memberPartyRepository, memberExerciseRepository);
- ExerciseConverter exerciseConverter = new ExerciseConverter(imageService);
+ ExerciseConverter exerciseConverter = new ExerciseConverter(fileService);
exerciseParticipationService = new ExerciseParticipationService(
exerciseRepository, memberRepository, memberPartyRepository,
memberExerciseRepository, guestRepository, exerciseValidator, exerciseConverter);
@@ -72,16 +75,221 @@ void setUp() {
PartyFixture.createPartyAddr("서울특별시", "강남구"));
ReflectionTestUtils.setField(party, "id", 10L);
- exercise = Exercise.builder()
- .date(LocalDate.of(2099, 12, 31))
- .startTime(LocalTime.of(10, 0))
- .endTime(LocalTime.of(12, 0))
- .maxCapacity(10)
- .partyGuestAccept(true)
- .outsideGuestAccept(false)
- .build();
+ exercise = ExerciseFixture.createExercise(party, LocalDate.of(2099, 12, 31),
+ LocalTime.of(12, 0), true, false);
ReflectionTestUtils.setField(exercise, "id", 100L);
- exercise.setParty(party);
+ }
+
+ @Nested
+ @DisplayName("joinExercise")
+ class JoinExercise {
+
+ @Nested
+ @DisplayName("성공 케이스")
+ class Success {
+
+ @Test
+ @DisplayName("파티 멤버가 운동 신청하면 Response를 반환한다")
+ void partyMember_joinExercise_success() {
+ // given
+ Member participant = MemberFixture.createMember("참여자", Gender.MALE, Level.B, 2001L, LocalDate.of(2000, 1, 1));
+ ReflectionTestUtils.setField(participant, "id", 2L);
+
+ given(memberExerciseRepository.existsByExerciseAndMember(exercise, participant)).willReturn(false);
+ given(memberPartyRepository.existsByPartyAndMember(party, participant)).willReturn(true);
+ given(memberExerciseRepository.save(any(MemberExercise.class)))
+ .willAnswer(invocation -> {
+ MemberExercise me = invocation.getArgument(0);
+ ReflectionTestUtils.setField(me, "id", 50L);
+ return me;
+ });
+
+ // when
+ ExerciseJoinDTO.Response response = exerciseParticipationService.joinExercise(exercise, participant);
+
+ // then
+ assertThat(response.participantId()).isEqualTo(50L);
+ assertThat(response.currentParticipants()).isNotNull();
+ }
+
+ @Test
+ @DisplayName("외부 참여자가 outsideGuestAccept=true 운동 신청하면 Response를 반환한다")
+ void outsideMember_joinExercise_success() {
+ // given
+ Exercise outsideAcceptExercise = ExerciseFixture.createExercise(party, LocalDate.of(2099, 12, 31),
+ LocalTime.of(12, 0), true, true);
+ ReflectionTestUtils.setField(outsideAcceptExercise, "id", 101L);
+
+ Member outsideMember = MemberFixture.createMember("외부참여자", Gender.FEMALE, Level.C, 3001L, LocalDate.of(2000, 1, 1));
+ ReflectionTestUtils.setField(outsideMember, "id", 3L);
+
+ given(memberExerciseRepository.existsByExerciseAndMember(outsideAcceptExercise, outsideMember)).willReturn(false);
+ given(memberPartyRepository.existsByPartyAndMember(party, outsideMember)).willReturn(false);
+ given(memberExerciseRepository.save(any(MemberExercise.class)))
+ .willAnswer(invocation -> {
+ MemberExercise me = invocation.getArgument(0);
+ ReflectionTestUtils.setField(me, "id", 51L);
+ return me;
+ });
+
+ // when
+ ExerciseJoinDTO.Response response = exerciseParticipationService.joinExercise(outsideAcceptExercise, outsideMember);
+
+ // then
+ assertThat(response.participantId()).isEqualTo(51L);
+ assertThat(response.currentParticipants()).isNotNull();
+ }
+ }
+
+ @Nested
+ @DisplayName("실패 케이스")
+ class Failure {
+
+ @Test
+ @DisplayName("이미 시작된 운동이면 ExerciseException(EXERCISE_ALREADY_STARTED_PARTICIPATION)을 던진다")
+ void alreadyStarted_throwsException() {
+ Exercise startedExercise = ExerciseFixture.createExercise(party, LocalDate.of(2000, 1, 1),
+ LocalTime.of(12, 0), true, false);
+ ReflectionTestUtils.setField(startedExercise, "id", 200L);
+
+ Member participant = MemberFixture.createMember("참여자", Gender.MALE, Level.B, 2001L);
+ ReflectionTestUtils.setField(participant, "id", 2L);
+
+ assertThatThrownBy(() ->
+ exerciseParticipationService.joinExercise(startedExercise, participant))
+ .isInstanceOf(ExerciseException.class)
+ .satisfies(e -> assertThat(((ExerciseException) e).getCode())
+ .isEqualTo(ExerciseErrorCode.EXERCISE_ALREADY_STARTED_PARTICIPATION));
+ }
+
+ @Test
+ @DisplayName("이미 참여 신청한 운동이면 ExerciseException(ALREADY_JOINED_EXERCISE)을 던진다")
+ void alreadyJoined_throwsException() {
+ Member participant = MemberFixture.createMember("참여자", Gender.MALE, Level.B, 2001L);
+ ReflectionTestUtils.setField(participant, "id", 2L);
+
+ given(memberExerciseRepository.existsByExerciseAndMember(exercise, participant)).willReturn(true);
+
+ assertThatThrownBy(() ->
+ exerciseParticipationService.joinExercise(exercise, participant))
+ .isInstanceOf(ExerciseException.class)
+ .satisfies(e -> assertThat(((ExerciseException) e).getCode())
+ .isEqualTo(ExerciseErrorCode.ALREADY_JOINED_EXERCISE));
+ }
+
+ @Test
+ @DisplayName("파티 멤버가 아닌데 외부 참여 불가 운동이면 ExerciseException(NOT_PARTY_MEMBER)을 던진다")
+ void notPartyMember_outsideNotAccepted_throwsException() {
+ Member outsideMember = MemberFixture.createMember("외부인", Gender.MALE, Level.B, 3001L);
+ ReflectionTestUtils.setField(outsideMember, "id", 3L);
+
+ given(memberExerciseRepository.existsByExerciseAndMember(exercise, outsideMember)).willReturn(false);
+ given(memberPartyRepository.existsByPartyAndMember(party, outsideMember)).willReturn(false);
+
+ assertThatThrownBy(() ->
+ exerciseParticipationService.joinExercise(exercise, outsideMember))
+ .isInstanceOf(ExerciseException.class)
+ .satisfies(e -> assertThat(((ExerciseException) e).getCode())
+ .isEqualTo(ExerciseErrorCode.NOT_PARTY_MEMBER));
+ }
+
+ @Test
+ @DisplayName("나이 조건 불일치면 ExerciseException(MEMBER_AGE_NOT_ALLOWED)을 던진다")
+ void ageNotAllowed_throwsException() {
+ Member youngMember = MemberFixture.createMember("어린회원", Gender.MALE, Level.B, 4001L, LocalDate.of(2010, 1, 1));
+ ReflectionTestUtils.setField(youngMember, "id", 4L);
+
+ given(memberExerciseRepository.existsByExerciseAndMember(exercise, youngMember)).willReturn(false);
+ given(memberPartyRepository.existsByPartyAndMember(party, youngMember)).willReturn(true);
+
+ assertThatThrownBy(() ->
+ exerciseParticipationService.joinExercise(exercise, youngMember))
+ .isInstanceOf(ExerciseException.class)
+ .satisfies(e -> assertThat(((ExerciseException) e).getCode())
+ .isEqualTo(ExerciseErrorCode.MEMBER_AGE_NOT_ALLOWED));
+ }
+ }
+ }
+
+
+
+
+ @Nested
+ @DisplayName("cancelParticipation")
+ class CancelParticipation {
+
+ @Nested
+ @DisplayName("성공 케이스")
+ class Success {
+
+ @Test
+ @DisplayName("참여자가 본인 참여를 취소하면 Response를 반환한다")
+ void cancelParticipation_success() {
+ // given
+ Member participant = MemberFixture.createMember("참여자", Gender.MALE, Level.B, 2001L);
+ ReflectionTestUtils.setField(participant, "id", 2L);
+
+ MemberExercise memberExercise = MemberFixture.createMemberExercise(participant, exercise);
+ ReflectionTestUtils.setField(memberExercise, "id", 50L);
+
+ given(memberExerciseRepository.findByExerciseAndMember(exercise, participant))
+ .willReturn(Optional.of(memberExercise));
+
+ // when
+ ExerciseCancelDTO.Response response = exerciseParticipationService
+ .cancelParticipation(exercise, participant);
+
+ // then
+ assertThat(response.memberName()).isEqualTo(participant.getMemberName());
+ assertThat(response.currentParticipants()).isNotNull();
+ then(memberExerciseRepository).should().delete(memberExercise);
+ then(exerciseRepository).should().save(exercise);
+ }
+ }
+
+ @Nested
+ @DisplayName("실패 케이스")
+ class Failure {
+
+ @Test
+ @DisplayName("이미 시작된 운동이면 ExerciseException(EXERCISE_ALREADY_STARTED_CANCEL)을 던진다")
+ void alreadyStarted_throwsException() {
+ Exercise startedExercise = ExerciseFixture.createExercise(party, LocalDate.of(2000, 1, 1),
+ LocalTime.of(12, 0), true, false);
+ ReflectionTestUtils.setField(startedExercise, "id", 200L);
+
+ Member participant = MemberFixture.createMember("참여자", Gender.MALE, Level.B, 2001L);
+ ReflectionTestUtils.setField(participant, "id", 2L);
+
+ MemberExercise memberExercise = MemberFixture.createMemberExercise(participant, startedExercise);
+ ReflectionTestUtils.setField(memberExercise, "id", 50L);
+
+ given(memberExerciseRepository.findByExerciseAndMember(startedExercise, participant))
+ .willReturn(Optional.of(memberExercise));
+
+ assertThatThrownBy(() ->
+ exerciseParticipationService.cancelParticipation(startedExercise, participant))
+ .isInstanceOf(ExerciseException.class)
+ .satisfies(e -> assertThat(((ExerciseException) e).getCode())
+ .isEqualTo(ExerciseErrorCode.EXERCISE_ALREADY_STARTED_CANCEL));
+ }
+
+ @Test
+ @DisplayName("참여 기록이 없으면 ExerciseException(MEMBER_EXERCISE_NOT_FOUND)을 던진다")
+ void memberExerciseNotFound_throwsException() {
+ Member participant = MemberFixture.createMember("참여자", Gender.MALE, Level.B, 2001L);
+ ReflectionTestUtils.setField(participant, "id", 2L);
+
+ given(memberExerciseRepository.findByExerciseAndMember(exercise, participant))
+ .willReturn(Optional.empty());
+
+ assertThatThrownBy(() ->
+ exerciseParticipationService.cancelParticipation(exercise, participant))
+ .isInstanceOf(ExerciseException.class)
+ .satisfies(e -> assertThat(((ExerciseException) e).getCode())
+ .isEqualTo(ExerciseErrorCode.MEMBER_EXERCISE_NOT_FOUND));
+ }
+ }
}
@Nested
@@ -133,9 +341,9 @@ void subManagerCancelsMemberParticipation_success() {
ExerciseCancelDTO.ByManagerRequest request = new ExerciseCancelDTO.ByManagerRequest(false);
- given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), subManager.getId(), Role.party_MANAGER))
+ given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), subManager.getId(), Role.PARTY_MANAGER))
.willReturn(false);
- given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), subManager.getId(), Role.party_SUBMANAGER))
+ given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), subManager.getId(), Role.PARTY_SUBMANAGER))
.willReturn(true);
given(memberRepository.findById(participant.getId())).willReturn(Optional.of(participant));
given(memberExerciseRepository.findByExerciseAndMember(exercise, participant))
@@ -184,9 +392,9 @@ void normalMember_throwsException() {
ExerciseCancelDTO.ByManagerRequest request = new ExerciseCancelDTO.ByManagerRequest(false);
- given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), normalMember.getId(), Role.party_MANAGER))
+ given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), normalMember.getId(), Role.PARTY_MANAGER))
.willReturn(false);
- given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), normalMember.getId(), Role.party_SUBMANAGER))
+ given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), normalMember.getId(), Role.PARTY_SUBMANAGER))
.willReturn(false);
assertThatThrownBy(() ->
@@ -199,16 +407,9 @@ void normalMember_throwsException() {
@Test
@DisplayName("이미 시작된 운동이면 ExerciseException(EXERCISE_ALREADY_STARTED_CANCEL)을 던진다")
void alreadyStarted_throwsException() {
- Exercise startedExercise = Exercise.builder()
- .date(LocalDate.of(2000, 1, 1))
- .startTime(LocalTime.of(10, 0))
- .endTime(LocalTime.of(12, 0))
- .maxCapacity(10)
- .partyGuestAccept(true)
- .outsideGuestAccept(false)
- .build();
+ Exercise startedExercise = ExerciseFixture.createExercise(party, LocalDate.of(2000, 1, 1),
+ LocalTime.of(12, 0), true, false);
ReflectionTestUtils.setField(startedExercise, "id", 200L);
- startedExercise.setParty(party);
ExerciseCancelDTO.ByManagerRequest request = new ExerciseCancelDTO.ByManagerRequest(false);
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
index da61c9fa6..1a8c53480 100644
--- a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java
+++ b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java
@@ -8,45 +8,78 @@
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.ExerciseAddr;
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.image.service.ImageService;
+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.enums.MemberStatus;
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")
@@ -62,7 +95,7 @@ class ExerciseQueryServiceTest {
@Mock private GuestRepository guestRepository;
@Mock private PartyRepository partyRepository;
@Mock private ExerciseBookmarkRepository exerciseBookmarkRepository;
- @Mock private ImageService imageService;
+ @Mock private FileService fileService;
private ExerciseConverter exerciseConverter;
@@ -72,7 +105,7 @@ class ExerciseQueryServiceTest {
@BeforeEach
void setUp() {
- exerciseConverter = new ExerciseConverter(imageService);
+ exerciseConverter = new ExerciseConverter(fileService);
ReflectionTestUtils.setField(exerciseQueryService, "exerciseConverter", exerciseConverter);
manager = MemberFixture.createMember("모임장", Gender.MALE, Level.A, 1001L);
@@ -85,15 +118,7 @@ void setUp() {
exercise = ExerciseFixture.createExercise(party, LocalDate.now().minusDays(1));
ReflectionTestUtils.setField(exercise, "id", 100L);
- ExerciseAddr exerciseAddr = ExerciseAddr.builder()
- .addr1("서울특별시")
- .addr2("강남구")
- .streetAddr("서울특별시 강남구 테헤란로 1")
- .buildingName("테스트 체육관")
- .latitude(37.5)
- .longitude(127.0)
- .build();
- ReflectionTestUtils.setField(exercise, "exerciseAddr", exerciseAddr);
+ ReflectionTestUtils.setField(exercise, "exerciseAddr", ExerciseFixture.createExerciseAddr());
}
@Nested
@@ -117,7 +142,7 @@ class Success {
given(guestRepository.findByExerciseId(exercise.getId()))
.willReturn(List.of());
given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(
- party.getId(), manager.getId(), Role.party_MANAGER))
+ party.getId(), manager.getId(), Role.PARTY_MANAGER))
.willReturn(true);
// when
@@ -129,8 +154,35 @@ class Success {
}
@Test
- @DisplayName("일반_멤버면_isManager_false로_반환된다")
- void 일반_멤버면_isManager_false로_반환된다() {
+ @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);
@@ -144,7 +196,7 @@ class Success {
given(guestRepository.findByExerciseId(exercise.getId()))
.willReturn(List.of());
given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(
- party.getId(), normalMember.getId(), Role.party_MANAGER))
+ party.getId(), normalMember.getId(), Role.PARTY_MANAGER))
.willReturn(false);
// when
@@ -155,22 +207,42 @@ class Success {
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 = Member.builder()
- .memberName("탈퇴회원")
- .nickname("탈퇴닉네임")
- .gender(Gender.MALE)
- .level(Level.C)
- .isActive(MemberStatus.INACTIVE)
- .socialId(9999L)
- .build();
+ Member withdrawnMember = MemberFixture.createWithdrawnMember("탈퇴회원", "탈퇴닉네임", 9999L);
ReflectionTestUtils.setField(withdrawnMember, "id", 99L);
MemberExercise memberExercise = MemberFixture.createMemberExercise(withdrawnMember, exercise);
- ReflectionTestUtils.setField(memberExercise, "createdAt", LocalDateTime.now());
given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId()))
.willReturn(Optional.of(exercise));
@@ -181,7 +253,7 @@ class Success {
given(guestRepository.findByExerciseId(exercise.getId()))
.willReturn(List.of());
given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(
- party.getId(), manager.getId(), Role.party_MANAGER))
+ party.getId(), manager.getId(), Role.PARTY_MANAGER))
.willReturn(true);
given(memberPartyRepository.findMemberRolesByPartyAndMembers(
party.getId(), List.of(withdrawnMember.getId())))
@@ -205,9 +277,8 @@ class Success {
ReflectionTestUtils.setField(activeMember, "id", 2L);
MemberExercise memberExercise = MemberFixture.createMemberExercise(activeMember, exercise);
- ReflectionTestUtils.setField(memberExercise, "createdAt", LocalDateTime.now());
- MemberParty memberParty = MemberFixture.createMemberParty(party, activeMember, Role.party_MEMBER);
+ MemberParty memberParty = MemberFixture.createMemberParty(party, activeMember, Role.PARTY_MEMBER);
given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId()))
.willReturn(Optional.of(exercise));
@@ -218,7 +289,7 @@ class Success {
given(guestRepository.findByExerciseId(exercise.getId()))
.willReturn(List.of());
given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(
- party.getId(), manager.getId(), Role.party_MANAGER))
+ party.getId(), manager.getId(), Role.PARTY_MANAGER))
.willReturn(true);
given(memberPartyRepository.findMemberRolesByPartyAndMembers(
party.getId(), List.of(activeMember.getId())))
@@ -238,6 +309,9 @@ class Success {
@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()))
@@ -245,10 +319,12 @@ class Success {
given(memberExerciseRepository.findByExerciseIdWithMemberAndProfile(exercise.getId()))
.willReturn(List.of());
given(guestRepository.findByExerciseId(exercise.getId()))
- .willReturn(List.of());
+ .willReturn(List.of(guest));
given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(
- party.getId(), manager.getId(), Role.party_MANAGER))
+ party.getId(), manager.getId(), Role.PARTY_MANAGER))
.willReturn(true);
+ given(memberRepository.findMemberNamesByIds(any()))
+ .willReturn(Map.of(manager.getId(), "모임장"));
// when
ExerciseDetailDTO.Response response = exerciseQueryService.getExerciseDetail(
@@ -256,7 +332,78 @@ class Success {
// then
List participants = response.participants().list();
- assertThat(participants).isEmpty();
+ 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
@@ -277,8 +424,8 @@ class Success {
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);
+ 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));
@@ -289,7 +436,7 @@ class Success {
given(guestRepository.findByExerciseId(exercise.getId()))
.willReturn(List.of());
given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(
- party.getId(), manager.getId(), Role.party_MANAGER))
+ party.getId(), manager.getId(), Role.PARTY_MANAGER))
.willReturn(true);
given(memberPartyRepository.findMemberRolesByPartyAndMembers(
party.getId(), List.of(firstMember.getId(), secondMember.getId())))
@@ -311,7 +458,6 @@ class Success {
// given
Guest guest = GuestFixture.createGuest(exercise, manager.getId());
ReflectionTestUtils.setField(guest, "id", 50L);
- ReflectionTestUtils.setField(guest, "createdAt", LocalDateTime.now());
given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId()))
.willReturn(Optional.of(exercise));
@@ -322,7 +468,7 @@ class Success {
given(guestRepository.findByExerciseId(exercise.getId()))
.willReturn(List.of(guest));
given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(
- party.getId(), manager.getId(), Role.party_MANAGER))
+ party.getId(), manager.getId(), Role.PARTY_MANAGER))
.willReturn(true);
given(memberRepository.findMemberNamesByIds(any()))
.willReturn(Map.of(manager.getId(), "모임장"));
@@ -354,8 +500,8 @@ class Success {
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);
+ 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));
@@ -366,7 +512,7 @@ class Success {
given(guestRepository.findByExerciseId(exercise.getId()))
.willReturn(List.of());
given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(
- party.getId(), manager.getId(), Role.party_MANAGER))
+ party.getId(), manager.getId(), Role.PARTY_MANAGER))
.willReturn(true);
given(memberPartyRepository.findMemberRolesByPartyAndMembers(
party.getId(), List.of(firstMember.getId(), secondMember.getId())))
@@ -403,8 +549,8 @@ class Success {
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);
+ 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));
@@ -415,7 +561,7 @@ class Success {
given(guestRepository.findByExerciseId(exercise.getId()))
.willReturn(List.of());
given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(
- party.getId(), manager.getId(), Role.party_MANAGER))
+ party.getId(), manager.getId(), Role.PARTY_MANAGER))
.willReturn(true);
given(memberPartyRepository.findMemberRolesByPartyAndMembers(
party.getId(), List.of(maleMember.getId(), femaleMember.getId())))
@@ -448,8 +594,8 @@ class Success {
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);
+ 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));
@@ -460,7 +606,7 @@ class Success {
given(guestRepository.findByExerciseId(exercise.getId()))
.willReturn(List.of());
given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(
- party.getId(), manager.getId(), Role.party_MANAGER))
+ party.getId(), manager.getId(), Role.PARTY_MANAGER))
.willReturn(true);
given(memberPartyRepository.findMemberRolesByPartyAndMembers(
party.getId(), List.of(maleMember.getId(), femaleMember.getId())))
@@ -509,4 +655,1702 @@ class Failure {
}
}
}
+
+ @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(mainAddr.getLatitude()), eq(mainAddr.getLongitude()), eq(3)))
+ .willReturn(List.of());
+
+ // when
+ ExerciseMapBuildingsDTO.Response response = exerciseQueryService.getExerciseMapCalendarSummary(
+ 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(3)))
+ .willReturn(List.of());
+
+ // when
+ ExerciseMapBuildingsDTO.Response response = exerciseQueryService.getExerciseMapCalendarSummary(
+ 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(
+ 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(
+ 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(
+ 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(
+ LocalDate.of(2026, 4, 1), 37.5, 127.0, radiusKm, memberWithoutMainAddr.getId()))
+ .isInstanceOf(ExerciseException.class)
+ .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.MAIN_ADDRESS_NULL);
+ }
+
+ @Test
+ @DisplayName("위도와 경도 중 하나만 주면 예외를 던진다")
+ void 위도와_경도_중_하나만_주면_예외를_던진다() {
+ // given
+ given(memberRepository.findMemberWithAddresses(mapMember.getId()))
+ .willReturn(Optional.of(mapMember));
+
+ // when & then
+ assertThatThrownBy(() -> exerciseQueryService.getExerciseMapCalendarSummary(
+ LocalDate.of(2026, 4, 1), 37.5, null, radiusKm, mapMember.getId()))
+ .isInstanceOf(ExerciseException.class)
+ .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.INCOMPLETE_LOCATION_INFO);
+ }
+ }
+
+ 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/ExerciseRecommendationServiceTest.java b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseRecommendationServiceTest.java
new file mode 100644
index 000000000..4420a0d00
--- /dev/null
+++ b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseRecommendationServiceTest.java
@@ -0,0 +1,304 @@
+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/file/integration/FileIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/file/integration/FileIntegrationTest.java
new file mode 100644
index 000000000..23f4c00af
--- /dev/null
+++ b/src/test/java/umc/cockple/demo/domain/file/integration/FileIntegrationTest.java
@@ -0,0 +1,157 @@
+package umc.cockple.demo.domain.file.integration;
+
+import org.junit.jupiter.api.*;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.MediaType;
+import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.test.web.servlet.MockMvc;
+import umc.cockple.demo.domain.file.dto.FileUploadDTO;
+import umc.cockple.demo.domain.file.service.FileService;
+import umc.cockple.demo.domain.member.domain.Member;
+import umc.cockple.demo.domain.member.repository.MemberRepository;
+import umc.cockple.demo.global.enums.DomainType;
+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.SecurityContextHelper;
+import umc.cockple.demo.support.fixture.MemberFixture;
+
+import java.util.List;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@DisplayName("File 통합 테스트")
+class FileIntegrationTest extends IntegrationTestBase {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private MemberRepository memberRepository;
+
+ @MockitoBean
+ private FileService fileService;
+
+ private Member member;
+
+ @BeforeEach
+ void setUp() {
+ member = memberRepository.save(MemberFixture.createMember("홍길동", Gender.MALE, Level.A, 1001L));
+ }
+
+ @AfterEach
+ void tearDown() {
+ memberRepository.deleteAll();
+ SecurityContextHelper.clearAuthentication();
+ }
+
+ @Nested
+ @DisplayName("POST /api/gcs/upload/file - 단일 파일 업로드")
+ class UploadFile {
+
+ @Nested
+ @DisplayName("성공 케이스")
+ class Success {
+
+ @Test
+ @DisplayName("200 - 정상적인 File과 DomainType이 주어지면 업로드 성공 응답을 반환한다")
+ void success_uploadSingleFile() throws Exception {
+ // 가상 파일 생성
+ MockMultipartFile mockFile = new MockMultipartFile(
+ "file",
+ "test.jpg",
+ MediaType.IMAGE_JPEG_VALUE,
+ "test image content".getBytes()
+ );
+
+ // 가상 응답 객체 생성
+ FileUploadDTO.Response mockResponse = FileUploadDTO.Response.builder()
+ .fileKey("chat/test-key.jpg")
+ .fileUrl("https://storage.googleapis.com/test-bucket/chat/test-key.jpg")
+ .originalFileName("test.jpg")
+ .fileSize(18L)
+ .fileType(MediaType.IMAGE_JPEG_VALUE)
+ .build();
+
+ given(fileService.uploadFile(any(), eq(DomainType.CHAT))).willReturn(mockResponse);
+ SecurityContextHelper.setAuthentication(member.getId(), member.getNickname());
+
+ mockMvc.perform(multipart("/api/gcs/upload/file")
+ .file(mockFile)
+ .param("domainType", "CHAT")
+ .contentType(MediaType.MULTIPART_FORM_DATA))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.fileKey").value("chat/test-key.jpg"))
+ .andExpect(jsonPath("$.data.fileUrl").value("https://storage.googleapis.com/test-bucket/chat/test-key.jpg"))
+ .andExpect(jsonPath("$.data.originalFileName").value("test.jpg"));
+ }
+ }
+
+ @Nested
+ @DisplayName("실패 케이스")
+ class Failure {
+
+ @Test
+ @DisplayName("400 - 파일 파라미터가 누락되면 실패 응답을 반환한다")
+ void fail_missingFileParam() throws Exception {
+ SecurityContextHelper.setAuthentication(member.getId(), member.getNickname());
+
+ mockMvc.perform(multipart("/api/gcs/upload/file")
+ .param("domainType", "CHAT")
+ .contentType(MediaType.MULTIPART_FORM_DATA))
+ .andExpect(status().isBadRequest());
+ }
+ }
+ }
+
+ @Nested
+ @DisplayName("POST /api/gcs/upload/files - 다중 파일 업로드")
+ class UploadFiles {
+
+ @Nested
+ @DisplayName("성공 케이스")
+ class Success {
+
+ @Test
+ @DisplayName("202 - 정상적인 파일 목록과 DomainType이 주어지면 업로드 성공 리스트를 반환한다")
+ void success_uploadMultipleFiles() throws Exception {
+ MockMultipartFile mockFile1 = new MockMultipartFile("file", "test1.jpg", MediaType.IMAGE_JPEG_VALUE, "content1".getBytes());
+ MockMultipartFile mockFile2 = new MockMultipartFile("file", "test2.png", MediaType.IMAGE_PNG_VALUE, "content2".getBytes());
+
+ FileUploadDTO.Response mockResponse1 = FileUploadDTO.Response.builder()
+ .fileKey("party/key1.jpg")
+ .fileUrl("https://url.com/party/key1.jpg")
+ .originalFileName("test1.jpg")
+ .fileSize(8L)
+ .fileType(MediaType.IMAGE_JPEG_VALUE)
+ .build();
+
+ FileUploadDTO.Response mockResponse2 = FileUploadDTO.Response.builder()
+ .fileKey("party/key2.png")
+ .fileUrl("https://url.com/party/key2.png")
+ .originalFileName("test2.png")
+ .fileSize(8L)
+ .fileType(MediaType.IMAGE_PNG_VALUE)
+ .build();
+
+ given(fileService.uploadFiles(any(), eq(DomainType.PARTY))).willReturn(List.of(mockResponse1, mockResponse2));
+ SecurityContextHelper.setAuthentication(member.getId(), member.getNickname());
+
+ mockMvc.perform(multipart("/api/gcs/upload/files")
+ .file(mockFile1)
+ .file(mockFile2)
+ .param("domainType", "PARTY")
+ .contentType(MediaType.MULTIPART_FORM_DATA))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data[0].fileKey").value("party/key1.jpg"))
+ .andExpect(jsonPath("$.data[1].fileKey").value("party/key2.png"));
+ }
+ }
+ }
+}
diff --git a/src/test/java/umc/cockple/demo/domain/file/service/FileServiceTest.java b/src/test/java/umc/cockple/demo/domain/file/service/FileServiceTest.java
new file mode 100644
index 000000000..8264de5ea
--- /dev/null
+++ b/src/test/java/umc/cockple/demo/domain/file/service/FileServiceTest.java
@@ -0,0 +1,171 @@
+package umc.cockple.demo.domain.file.service;
+
+import com.google.cloud.storage.Blob;
+import com.google.cloud.storage.BlobId;
+import com.google.cloud.storage.BlobInfo;
+import com.google.cloud.storage.Storage;
+import com.google.cloud.storage.StorageException;
+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.http.MediaType;
+import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.test.util.ReflectionTestUtils;
+import umc.cockple.demo.domain.file.dto.FileUploadDTO;
+import umc.cockple.demo.domain.file.exception.GcsErrorCode;
+import umc.cockple.demo.domain.file.exception.GcsException;
+import umc.cockple.demo.global.enums.DomainType;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+@DisplayName("FileService 단위 테스트")
+class FileServiceTest {
+
+ @Mock
+ private Storage storage;
+
+ private FileService fileService;
+
+ private String bucketName = "test-bucket";
+ private MockMultipartFile mockFile;
+
+ @BeforeEach
+ void setUp() {
+ fileService = new FileService(storage);
+ ReflectionTestUtils.setField(fileService, "bucket", bucketName);
+ mockFile = new MockMultipartFile("file", "test.jpg", MediaType.IMAGE_JPEG_VALUE, "test image content".getBytes());
+ }
+
+ // ========== uploadFile ==========
+ @Nested
+ @DisplayName("uploadFile - 단일 파일 업로드 테스트")
+ class UploadFile {
+
+ @Test
+ @DisplayName("정상적인 MultipartFile이 주어지면 GCS에 업로드하고 응답 DTO를 반환한다")
+ void success() {
+ given(storage.create(any(BlobInfo.class), any(byte[].class))).willReturn(mock(Blob.class));
+
+ FileUploadDTO.Response response = fileService.uploadFile(mockFile, DomainType.CHAT);
+
+ assertThat(response).isNotNull();
+ assertThat(response.fileKey()).startsWith("chat/");
+ assertThat(response.fileKey()).endsWith(".jpg");
+ assertThat(response.fileUrl()).isEqualTo("https://storage.googleapis.com/" + bucketName + "/" + response.fileKey());
+ assertThat(response.originalFileName()).isEqualTo("test.jpg");
+ verify(storage).create(any(BlobInfo.class), any(byte[].class));
+ }
+
+ @Test
+ @DisplayName("파일이 비어있으면 null을 반환한다")
+ void returnNullWhenFileIsEmpty() {
+ MockMultipartFile emptyFile = new MockMultipartFile("file", "empty.jpg", "image/jpeg", new byte[0]);
+
+ FileUploadDTO.Response response = fileService.uploadFile(emptyFile, DomainType.CHAT);
+
+ assertThat(response).isNull();
+ verify(storage, never()).create(any(BlobInfo.class), any(byte[].class));
+ }
+
+ @Test
+ @DisplayName("StorageException 발생 시 FILE_UPLOAD_GCS_EXCEPTION이 발생한다")
+ void throwExceptionWhenGcsError() {
+ given(storage.create(any(BlobInfo.class), any(byte[].class))).willThrow(new StorageException(500, "GCS 에러"));
+
+ GcsException exception = assertThrows(GcsException.class, () -> fileService.uploadFile(mockFile, DomainType.CHAT));
+
+ assertThat(exception.getCode()).isEqualTo(GcsErrorCode.FILE_UPLOAD_GCS_EXCEPTION);
+ }
+ }
+
+ // ========== uploadFiles ==========
+ @Nested
+ @DisplayName("uploadFiles - 다중 파일 업로드 테스트")
+ class UploadFiles {
+
+ @Test
+ @DisplayName("정상적인 파일 목록이 주어지면 업로드된 응답 DTO 리스트를 반환한다")
+ void success() {
+ MockMultipartFile mockFile2 = new MockMultipartFile("file", "test2.png", MediaType.IMAGE_PNG_VALUE, "content2".getBytes());
+ given(storage.create(any(BlobInfo.class), any(byte[].class))).willReturn(mock(Blob.class));
+
+ List responses = fileService.uploadFiles(List.of(mockFile, mockFile2), DomainType.PARTY);
+
+ assertThat(responses).hasSize(2);
+ assertThat(responses.get(0).originalFileName()).isEqualTo("test.jpg");
+ assertThat(responses.get(1).originalFileName()).isEqualTo("test2.png");
+ // 2번 호출됐는지 확인
+ verify(storage, times(2)).create(any(BlobInfo.class), any(byte[].class));
+ }
+
+ @Test
+ @DisplayName("파일 리스트가 null이거나 비어있으면 빈 리스트를 반환한다")
+ void returnEmptyListWhenFilesAreEmpty() {
+ List responses = fileService.uploadFiles(List.of(), DomainType.PARTY);
+
+ assertThat(responses).isEmpty();
+ verify(storage, never()).create(any(BlobInfo.class), any(byte[].class));
+ }
+ }
+
+ // ========== delete ==========
+ @Nested
+ @DisplayName("delete - 파일 삭제 테스트")
+ class Delete {
+
+ @Test
+ @DisplayName("유효한 fileKey가 주어지면 정상적으로 삭제를 수행한다")
+ void success() {
+ String fileKey = "chat/uuid-1234.jpg";
+ BlobId expectedBlobId = BlobId.of(bucketName, fileKey);
+ given(storage.delete(expectedBlobId)).willReturn(true);
+
+ fileService.delete(fileKey);
+
+ verify(storage).delete(expectedBlobId);
+ }
+ }
+
+ // ========== downloadFile ==========
+ @Nested
+ @DisplayName("downloadFile - 파일 다운로드(Blob 조회) 테스트")
+ class DownloadFile {
+
+ @Test
+ @DisplayName("유효한 fileKey를 주면 GCS에서 Blob 객체를 반환한다")
+ void success() {
+ String fileKey = "chat/valid-key.jpg";
+ Blob mockBlob = mock(Blob.class);
+ given(storage.get(BlobId.of(bucketName, fileKey))).willReturn(mockBlob);
+
+ Blob result = fileService.downloadFile(fileKey);
+
+ assertThat(result).isNotNull();
+ assertThat(result).isEqualTo(mockBlob);
+ }
+
+ @Test
+ @DisplayName("Blob이 존재하지 않으면 FILE_DELETE_EXCEPTION 예외가 발생한다")
+ void throwExceptionWhenBlobIsNull() {
+ String fileKey = "chat/not-found.jpg";
+ given(storage.get(BlobId.of(bucketName, fileKey))).willReturn(null);
+
+ GcsException exception = assertThrows(GcsException.class, () ->
+ fileService.downloadFile(fileKey)
+ );
+
+ assertThat(exception.getCode()).isEqualTo(GcsErrorCode.FILE_DELETE_EXCEPTION);
+ }
+ }
+}
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
new file mode 100644
index 000000000..28d577edc
--- /dev/null
+++ b/src/test/java/umc/cockple/demo/domain/member/integration/MemberIntegrationTest.java
@@ -0,0 +1,812 @@
+package umc.cockple.demo.domain.member.integration;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.*;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.test.web.servlet.MockMvc;
+import umc.cockple.demo.domain.chat.domain.ChatRoom;
+import umc.cockple.demo.domain.chat.domain.ChatRoomMember;
+import umc.cockple.demo.domain.chat.repository.ChatRoomMemberRepository;
+import umc.cockple.demo.domain.chat.repository.ChatRoomRepository;
+import umc.cockple.demo.domain.contest.domain.Contest;
+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.service.FileService;
+import umc.cockple.demo.domain.member.domain.*;
+import umc.cockple.demo.domain.member.dto.CreateMemberAddrDTO;
+import umc.cockple.demo.domain.member.dto.UpdateProfileRequestDTO;
+import umc.cockple.demo.domain.member.enums.MemberStatus;
+import umc.cockple.demo.domain.member.exception.MemberErrorCode;
+import umc.cockple.demo.domain.member.repository.*;
+import umc.cockple.demo.domain.party.domain.Party;
+import umc.cockple.demo.domain.party.domain.PartyAddr;
+import umc.cockple.demo.domain.party.enums.ParticipationType;
+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.Keyword;
+import umc.cockple.demo.global.enums.Level;
+import umc.cockple.demo.global.enums.Role;
+import umc.cockple.demo.global.oauth2.service.KakaoOauthService;
+import umc.cockple.demo.support.IntegrationTestBase;
+import umc.cockple.demo.support.SecurityContextHelper;
+import umc.cockple.demo.support.fixture.ChatFixture;
+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.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.hamcrest.Matchers.hasSize;
+import static org.mockito.BDDMockito.given;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+class MemberIntegrationTest extends IntegrationTestBase {
+
+ @Autowired MockMvc mockMvc;
+ @Autowired ObjectMapper objectMapper;
+ @Autowired MemberRepository memberRepository;
+ @Autowired MemberAddrRepository memberAddrRepository;
+ @Autowired MemberPartyRepository memberPartyRepository;
+ @Autowired PartyRepository partyRepository;
+ @Autowired PartyAddrRepository partyAddrRepository;
+
+ @MockitoBean KakaoOauthService kakaoOauthService;
+ @MockitoBean FileService fileService;
+
+ @Autowired ContestRepository contestRepository;
+ @Autowired MemberExerciseRepository memberExerciseRepository;
+ @Autowired MemberKeywordRepository memberKeywordRepository;
+ @Autowired ChatRoomRepository chatRoomRepository;
+ @Autowired ChatRoomMemberRepository chatRoomMemberRepository;
+
+ private Member member;
+
+ @BeforeEach
+ void setUp() {
+ member = memberRepository.save(MemberFixture.createMember("홍길동", Gender.MALE, Level.A, 1001L));
+ }
+
+ @AfterEach
+ void tearDown() {
+ chatRoomRepository.deleteAll(); // cascade: ChatRoomMember 함께 삭제
+ memberPartyRepository.deleteAll();
+ partyRepository.deleteAll();
+ partyAddrRepository.deleteAll();
+ memberRepository.deleteAll(); // cascade: MemberAddr, MemberKeyword 등 함께 삭제
+ SecurityContextHelper.clearAuthentication();
+ }
+
+
+ @Nested
+ @DisplayName("PATCH /api/member - 회원 탈퇴")
+ class WithdrawMember {
+
+ @Nested
+ @DisplayName("성공")
+ class Success {
+
+ @Test
+ @DisplayName("200 - 일반 멤버가 탈퇴하면 성공한다")
+ void normalMember_withdrawSuccess() throws Exception {
+ SecurityContextHelper.setAuthentication(member.getId(), member.getNickname());
+
+ mockMvc.perform(patch("/api/member"))
+ .andExpect(status().isOk());
+ }
+ }
+
+ @Nested
+ @DisplayName("실패")
+ class Failure {
+
+ @Test
+ @DisplayName("400 - 모임장은 탈퇴할 수 없다")
+ void manager_cannotWithdraw() throws Exception {
+ PartyAddr addr = partyAddrRepository.save(PartyFixture.createPartyAddr("서울특별시", "강남구"));
+ Party party = partyRepository.save(PartyFixture.createParty("테스트 모임", member.getId(), addr));
+ memberPartyRepository.save(MemberFixture.createMemberParty(party, member, Role.PARTY_MANAGER));
+
+ SecurityContextHelper.setAuthentication(member.getId(), member.getNickname());
+
+ mockMvc.perform(patch("/api/member"))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(MemberErrorCode.MANAGER_CANNOT_LEAVE.getCode()))
+ .andExpect(jsonPath("$.message").value(MemberErrorCode.MANAGER_CANNOT_LEAVE.getMessage()));
+ }
+
+ @Test
+ @DisplayName("400 - 부모임장은 탈퇴할 수 없다")
+ void subManager_cannotWithdraw() throws Exception {
+ PartyAddr addr = partyAddrRepository.save(PartyFixture.createPartyAddr("서울특별시", "강남구"));
+ Party party = partyRepository.save(PartyFixture.createParty("테스트 모임", member.getId(), addr));
+ memberPartyRepository.save(MemberFixture.createMemberParty(party, member, Role.PARTY_SUBMANAGER));
+
+ SecurityContextHelper.setAuthentication(member.getId(), member.getNickname());
+
+ mockMvc.perform(patch("/api/member"))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(MemberErrorCode.SUBMANAGER_CANNOT_LEAVE.getCode()))
+ .andExpect(jsonPath("$.message").value(MemberErrorCode.SUBMANAGER_CANNOT_LEAVE.getMessage()));
+ }
+ }
+ }
+
+ @Nested
+ @DisplayName("GET /api/profile/{memberId} - 타인 프로필 조회")
+ class GetProfile {
+
+ @Nested
+ @DisplayName("성공")
+ class Success {
+
+ @Test
+ @DisplayName("200 - 모든 필드가 정상 반환된다")
+ void getProfile_모든_필드가_정상_반환된다() throws Exception {
+ // given
+ given(fileService.getUrlFromKey("profile/test-key.jpg"))
+ .willReturn("https://storage.googleapis.com/test-bucket/profile/test-key.jpg");
+
+ Member freshMember = memberRepository.save(Member.builder()
+ .memberName("홍길동")
+ .nickname("홍길동")
+ .gender(Gender.MALE)
+ .birth(LocalDate.of(1990, 1, 1))
+ .level(Level.A)
+ .isActive(MemberStatus.ACTIVE)
+ .socialId(9001L)
+ .build());
+
+ // ProfileImg: Member cascade를 통해 저장
+ ProfileImg profileImg = ProfileImg.builder()
+ .member(freshMember)
+ .imgKey("profile/test-key.jpg")
+ .build();
+ freshMember.updateProfileImg(profileImg);
+ memberRepository.save(freshMember);
+
+ // 금2, 은1, 동1
+ for (int i = 0; i < 2; i++) {
+ contestRepository.save(Contest.builder()
+ .member(freshMember)
+ .contestName("금메달 대회")
+ .medalType(MedalType.GOLD)
+ .type(ParticipationType.SINGLE)
+ .level(Level.A)
+ .contentIsOpen(true)
+ .videoIsOpen(false)
+ .build());
+ }
+ contestRepository.save(Contest.builder()
+ .member(freshMember)
+ .contestName("은메달 대회")
+ .medalType(MedalType.SILVER)
+ .type(ParticipationType.SINGLE)
+ .level(Level.A)
+ .contentIsOpen(true)
+ .videoIsOpen(false)
+ .build());
+ contestRepository.save(Contest.builder()
+ .member(freshMember)
+ .contestName("동메달 대회")
+ .medalType(MedalType.BRONZE)
+ .type(ParticipationType.SINGLE)
+ .level(Level.A)
+ .contentIsOpen(true)
+ .videoIsOpen(false)
+ .build());
+
+ // 모임 2개
+ memberPartyRepository.save(MemberFixture.createMemberParty(null, freshMember, Role.PARTY_MEMBER));
+ memberPartyRepository.save(MemberFixture.createMemberParty(null, freshMember, Role.PARTY_MEMBER));
+
+ SecurityContextHelper.setAuthentication(freshMember.getId(), freshMember.getNickname());
+
+ // when & then
+ mockMvc.perform(get("/api/profile/{memberId}", freshMember.getId()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.memberName").value("홍길동"))
+ .andExpect(jsonPath("$.data.birth").value("1990-01-01"))
+ .andExpect(jsonPath("$.data.gender").value("MALE"))
+ .andExpect(jsonPath("$.data.level").value("A"))
+ .andExpect(jsonPath("$.data.profileImgUrl").value("https://storage.googleapis.com/test-bucket/profile/test-key.jpg"))
+ .andExpect(jsonPath("$.data.myPartyCnt").value(2))
+ .andExpect(jsonPath("$.data.myGoldMedalCnt").value(2))
+ .andExpect(jsonPath("$.data.mySilverMedalCnt").value(1))
+ .andExpect(jsonPath("$.data.myBronzeMedalCnt").value(1));
+ }
+ }
+
+ @Nested
+ @DisplayName("실패")
+ class Failure {
+
+ @Test
+ @DisplayName("404 - 존재하지 않는 멤버 조회 시 MEMBER_NOT_FOUND 에러를 반환한다")
+ void memberNotFound() throws Exception {
+ SecurityContextHelper.setAuthentication(member.getId(), member.getNickname());
+
+ mockMvc.perform(get("/api/profile/{memberId}", 999L))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.code").value(MemberErrorCode.MEMBER_NOT_FOUND.getCode()))
+ .andExpect(jsonPath("$.message").value(MemberErrorCode.MEMBER_NOT_FOUND.getMessage()));
+ }
+ }
+ }
+
+
+ @Nested
+ @DisplayName("GET /api/my/profile - 내 프로필 조회")
+ class GetMyProfile {
+
+ @Nested
+ @DisplayName("성공")
+ class Success {
+
+ @Test
+ @DisplayName("200 - 대표 주소가 있으면 내 프로필이 반환된다")
+ void getMyProfile_success() throws Exception {
+ memberAddrRepository.save(MemberAddrFixture.createMainAddr(member));
+ SecurityContextHelper.setAuthentication(member.getId(), member.getNickname());
+
+ mockMvc.perform(get("/api/my/profile"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.memberName").value("홍길동"))
+ .andExpect(jsonPath("$.data.addr3").value("역삼동"))
+ .andExpect(jsonPath("$.data.myExerciseCnt").value(0));
+ }
+
+ @Test
+ @DisplayName("200 - 모든 필드가 정상 반환된다")
+ void getMyProfile_모든_필드가_정상_반환된다() throws Exception {
+ // given
+ given(fileService.getUrlFromKey("profile/test-key.jpg"))
+ .willReturn("https://storage.googleapis.com/test-bucket/profile/test-key.jpg");
+
+ Member freshMember = memberRepository.save(Member.builder()
+ .memberName("홍길동")
+ .nickname("홍길동")
+ .gender(Gender.MALE)
+ .birth(LocalDate.of(1990, 1, 1))
+ .level(Level.A)
+ .isActive(MemberStatus.ACTIVE)
+ .socialId(9002L)
+ .build());
+
+ memberAddrRepository.save(MemberAddrFixture.createMainAddr(freshMember));
+
+ // ProfileImg: Member cascade를 통해 저장
+ ProfileImg profileImg = ProfileImg.builder()
+ .member(freshMember)
+ .imgKey("profile/test-key.jpg")
+ .build();
+ freshMember.updateProfileImg(profileImg);
+ memberRepository.save(freshMember);
+
+ // 금2, 은1, 동1
+ for (int i = 0; i < 2; i++) {
+ contestRepository.save(Contest.builder()
+ .member(freshMember)
+ .contestName("금메달 대회")
+ .medalType(MedalType.GOLD)
+ .type(ParticipationType.SINGLE)
+ .level(Level.A)
+ .contentIsOpen(true)
+ .videoIsOpen(false)
+ .build());
+ }
+ contestRepository.save(Contest.builder()
+ .member(freshMember)
+ .contestName("은메달 대회")
+ .medalType(MedalType.SILVER)
+ .type(ParticipationType.SINGLE)
+ .level(Level.A)
+ .contentIsOpen(true)
+ .videoIsOpen(false)
+ .build());
+ contestRepository.save(Contest.builder()
+ .member(freshMember)
+ .contestName("동메달 대회")
+ .medalType(MedalType.BRONZE)
+ .type(ParticipationType.SINGLE)
+ .level(Level.A)
+ .contentIsOpen(true)
+ .videoIsOpen(false)
+ .build());
+
+ // 모임 2개, 운동 2개, 키워드 2개
+ memberPartyRepository.save(MemberFixture.createMemberParty(null, freshMember, Role.PARTY_MEMBER));
+ memberPartyRepository.save(MemberFixture.createMemberParty(null, freshMember, Role.PARTY_MEMBER));
+
+ memberExerciseRepository.save(MemberExercise.builder()
+ .member(freshMember)
+ .exerciseMemberShipStatus(ExerciseMemberShipStatus.PARTY_MEMBER)
+ .build());
+ memberExerciseRepository.save(MemberExercise.builder()
+ .member(freshMember)
+ .exerciseMemberShipStatus(ExerciseMemberShipStatus.PARTY_MEMBER)
+ .build());
+
+ memberKeywordRepository.save(MemberKeyword.builder()
+ .member(freshMember)
+ .keyword(Keyword.FRIENDSHIP)
+ .build());
+ memberKeywordRepository.save(MemberKeyword.builder()
+ .member(freshMember)
+ .keyword(Keyword.FREE)
+ .build());
+
+ SecurityContextHelper.setAuthentication(freshMember.getId(), freshMember.getNickname());
+
+ // when & then
+ mockMvc.perform(get("/api/my/profile"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.memberName").value("홍길동"))
+ .andExpect(jsonPath("$.data.birth").value("1990-01-01"))
+ .andExpect(jsonPath("$.data.gender").value("MALE"))
+ .andExpect(jsonPath("$.data.level").value("A"))
+ .andExpect(jsonPath("$.data.keywords", hasSize(2)))
+ .andExpect(jsonPath("$.data.addr3").value("역삼동"))
+ .andExpect(jsonPath("$.data.streetAddr").value("테헤란로 123"))
+ .andExpect(jsonPath("$.data.buildingName").value("ㅁㅁ빌딩"))
+ .andExpect(jsonPath("$.data.latitude").value(37.5))
+ .andExpect(jsonPath("$.data.longitude").value(127.0))
+ .andExpect(jsonPath("$.data.profileImgUrl").value("https://storage.googleapis.com/test-bucket/profile/test-key.jpg"))
+ .andExpect(jsonPath("$.data.myPartyCnt").value(2))
+ .andExpect(jsonPath("$.data.myExerciseCnt").value(2))
+ .andExpect(jsonPath("$.data.myGoldMedalCnt").value(2))
+ .andExpect(jsonPath("$.data.mySilverMedalCnt").value(1))
+ .andExpect(jsonPath("$.data.myBronzeMedalCnt").value(1));
+ }
+ }
+
+ @Nested
+ @DisplayName("실패")
+ class Failure {
+
+ @Test
+ @DisplayName("400 - 대표 주소가 없으면 MAIN_ADDRESS_NULL 에러를 반환한다")
+ void noMainAddress_fail() throws Exception {
+ SecurityContextHelper.setAuthentication(member.getId(), member.getNickname());
+
+ mockMvc.perform(get("/api/my/profile"))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(MemberErrorCode.MAIN_ADDRESS_NULL.getCode()))
+ .andExpect(jsonPath("$.message").value(MemberErrorCode.MAIN_ADDRESS_NULL.getMessage()));
+ }
+ }
+ }
+
+ @Nested
+ @DisplayName("PATCH /api/my/profile - 프로필 수정")
+ class UpdateProfile {
+
+ @Nested
+ @DisplayName("성공")
+ class Success {
+
+ @Test
+ @DisplayName("200 - 모든 필드가 정상 업데이트된다")
+ void 모든_필드가_정상_업데이트된다() throws Exception {
+ // given - 기존 키워드 등록
+ memberKeywordRepository.save(MemberKeyword.builder()
+ .member(member).keyword(Keyword.FREE).build());
+
+ UpdateProfileRequestDTO request = new UpdateProfileRequestDTO(
+ "김길동", LocalDate.of(1995, 6, 15), Level.B,
+ List.of(Keyword.FRIENDSHIP, Keyword.MANAGER_MATCH), "profile/new-key.jpg");
+
+ SecurityContextHelper.setAuthentication(member.getId(), member.getNickname());
+
+ // when
+ mockMvc.perform(patch("/api/my/profile")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isOk());
+
+ // then - DB에서 모든 필드 검증
+ Member updated = memberRepository.findMemberWithProfileById(member.getId()).orElseThrow();
+ assertThat(updated.getMemberName()).isEqualTo("김길동");
+ assertThat(updated.getBirth()).isEqualTo(LocalDate.of(1995, 6, 15));
+ assertThat(updated.getLevel()).isEqualTo(Level.B);
+ assertThat(updated.getProfileImg()).isNotNull();
+ assertThat(updated.getProfileImg().getImgKey()).isEqualTo("profile/new-key.jpg");
+
+ List keywords = memberKeywordRepository.findAllByMemberId(member.getId());
+ assertThat(keywords).hasSize(2);
+ assertThat(keywords.stream().map(MemberKeyword::getKeyword).toList())
+ .containsExactlyInAnyOrder(Keyword.FRIENDSHIP, Keyword.MANAGER_MATCH);
+ }
+
+ @Test
+ @DisplayName("200 - imgKey가 없으면 이미지 없이 프로필이 업데이트된다")
+ void imgKey가_없으면_이미지_없이_프로필이_업데이트된다() throws Exception {
+ // given
+ UpdateProfileRequestDTO request = new UpdateProfileRequestDTO(
+ "김길동", LocalDate.of(1990, 1, 1), Level.A,
+ List.of(Keyword.FRIENDSHIP), null);
+
+ SecurityContextHelper.setAuthentication(member.getId(), member.getNickname());
+
+ // when
+ mockMvc.perform(patch("/api/my/profile")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isOk());
+
+ // then
+ Member updated = memberRepository.findMemberWithProfileById(member.getId()).orElseThrow();
+ assertThat(updated.getMemberName()).isEqualTo("김길동");
+ assertThat(updated.getProfileImg()).isNull();
+ }
+
+ @Test
+ @DisplayName("200 - 기존 이미지가 있고 imgKey가 다르면 이미지가 변경된다")
+ void 기존_이미지가_있고_imgKey가_다르면_이미지가_변경된다() throws Exception {
+ // given - 기존 프로필 이미지 설정
+ ProfileImg existingImg = ProfileImg.builder()
+ .member(member).imgKey("profile/old-key.jpg").build();
+ member.updateProfileImg(existingImg);
+ memberRepository.save(member);
+
+ UpdateProfileRequestDTO request = new UpdateProfileRequestDTO(
+ "김길동", LocalDate.of(1990, 1, 1), Level.A,
+ List.of(Keyword.FRIENDSHIP), "profile/new-key.jpg");
+
+ SecurityContextHelper.setAuthentication(member.getId(), member.getNickname());
+
+ // when
+ mockMvc.perform(patch("/api/my/profile")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isOk());
+
+ // then
+ Member updated = memberRepository.findMemberWithProfileById(member.getId()).orElseThrow();
+ assertThat(updated.getProfileImg().getImgKey()).isEqualTo("profile/new-key.jpg");
+ }
+
+ @Test
+ @DisplayName("200 - DIRECT 채팅방 상대방의 displayName이 업데이트된다")
+ void DIRECT_채팅방_상대방의_displayName이_업데이트된다() throws Exception {
+ // given
+ Member counterPart = memberRepository.save(
+ MemberFixture.createMember("상대방", Gender.FEMALE, Level.B, 2001L));
+
+ ChatRoom directRoom = ChatRoom.createDirectChatRoom();
+ directRoom.addChatRoomMember(
+ ChatFixture.createJoinedMember(directRoom, member, "홍길동"));
+ directRoom.addChatRoomMember(
+ ChatFixture.createJoinedMember(directRoom, counterPart, "홍길동"));
+ chatRoomRepository.save(directRoom);
+
+ UpdateProfileRequestDTO request = new UpdateProfileRequestDTO(
+ "김길동", LocalDate.of(1990, 1, 1), Level.A,
+ List.of(Keyword.FRIENDSHIP), null);
+
+ SecurityContextHelper.setAuthentication(member.getId(), member.getNickname());
+
+ // when
+ mockMvc.perform(patch("/api/my/profile")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isOk());
+
+ // then - 상대방의 ChatRoomMember displayName이 업데이트되었는지 검증
+ ChatRoomMember updatedCounterPart = chatRoomMemberRepository
+ .findByChatRoomIdAndMemberId(directRoom.getId(), counterPart.getId())
+ .orElseThrow();
+ assertThat(updatedCounterPart.getDisplayName()).isEqualTo("김길동");
+ }
+
+ @Test
+ @DisplayName("200 - PARTY 채팅방의 displayName은 변경되지 않는다")
+ void PARTY_채팅방의_displayName은_변경되지_않는다() throws Exception {
+ // given
+ PartyAddr addr = partyAddrRepository.save(PartyFixture.createPartyAddr("서울특별시", "강남구"));
+ Party party = partyRepository.save(PartyFixture.createParty("테스트 모임", member.getId(), addr));
+
+ ChatRoom partyRoom = ChatRoom.createPartyChatRoom(party);
+ partyRoom.addChatRoomMember(
+ ChatFixture.createJoinedMember(partyRoom, member, "홍길동"));
+ chatRoomRepository.save(partyRoom);
+
+ UpdateProfileRequestDTO request = new UpdateProfileRequestDTO(
+ "김길동", LocalDate.of(1990, 1, 1), Level.A,
+ List.of(Keyword.FRIENDSHIP), null);
+
+ SecurityContextHelper.setAuthentication(member.getId(), member.getNickname());
+
+ // when
+ mockMvc.perform(patch("/api/my/profile")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isOk());
+
+ // then - PARTY 채팅방의 ChatRoomMember displayName은 변경되지 않아야 한다
+ ChatRoomMember partyChatMember = chatRoomMemberRepository
+ .findByChatRoomIdAndMemberId(partyRoom.getId(), member.getId())
+ .orElseThrow();
+ assertThat(partyChatMember.getDisplayName()).isEqualTo("홍길동");
+ }
+ }
+
+ @Nested
+ @DisplayName("실패")
+ class Failure {
+
+ @Test
+ @DisplayName("404 - 존재하지 않는 회원이면 MEMBER_NOT_FOUND 에러를 반환한다")
+ void 존재하지_않는_회원이면_MEMBER_NOT_FOUND_에러를_반환한다() throws Exception {
+ UpdateProfileRequestDTO request = new UpdateProfileRequestDTO(
+ "김길동", LocalDate.of(1990, 1, 1), Level.A,
+ List.of(Keyword.FRIENDSHIP), null);
+
+ SecurityContextHelper.setAuthentication(999L, "없는회원");
+
+ mockMvc.perform(patch("/api/my/profile")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.code").value(MemberErrorCode.MEMBER_NOT_FOUND.getCode()))
+ .andExpect(jsonPath("$.message").value(MemberErrorCode.MEMBER_NOT_FOUND.getMessage()));
+ }
+ }
+ }
+
+ @Nested
+ @DisplayName("POST /api/my/profile/locations - 주소 추가")
+ class AddAddress {
+
+ private CreateMemberAddrDTO.CreateMemberAddrRequestDTO validRequest;
+
+ @BeforeEach
+ void setUp() {
+ validRequest = new CreateMemberAddrDTO.CreateMemberAddrRequestDTO(
+ "서울특별시", "강남구", "역삼동", "테헤란로 123", "ㅁㅁ빌딩", 37.5, 127.0);
+ }
+
+ @Nested
+ @DisplayName("성공")
+ class Success {
+
+ @Test
+ @DisplayName("200 - 새 주소를 추가하면 memberAddrId를 반환한다")
+ void addAddress_success() throws Exception {
+ SecurityContextHelper.setAuthentication(member.getId(), member.getNickname());
+
+ mockMvc.perform(post("/api/my/profile/locations")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(validRequest)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.memberAddrId").isNumber());
+ }
+ }
+
+ @Nested
+ @DisplayName("실패")
+ class Failure {
+
+ @Test
+ @DisplayName("400 - 같은 주소를 중복 추가하면 DUPLICATE_ADDRESS 에러를 반환한다")
+ void duplicateAddress_fail() throws Exception {
+ memberAddrRepository.save(MemberAddrFixture.createMainAddr(member)); // validRequest와 동일한 값
+ SecurityContextHelper.setAuthentication(member.getId(), member.getNickname());
+
+ mockMvc.perform(post("/api/my/profile/locations")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(validRequest)))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(MemberErrorCode.DUPLICATE_ADDRESS.getCode()))
+ .andExpect(jsonPath("$.message").value(MemberErrorCode.DUPLICATE_ADDRESS.getMessage()));
+ }
+
+ @Test
+ @DisplayName("400 - 주소가 이미 5개면 OVER_NUMBER_OF_ADDR 에러를 반환한다")
+ void overNumberOfAddr_fail() throws Exception {
+ // validRequest(강남구)와 addr2가 달라 중복 검사를 통과하도록 마포구 주소 5개 생성
+ for (int i = 1; i <= 5; i++) {
+ memberAddrRepository.save(MemberAddrFixture.createAddr(member, "동" + i, "길로 " + i, i == 1));
+ }
+ SecurityContextHelper.setAuthentication(member.getId(), member.getNickname());
+
+ mockMvc.perform(post("/api/my/profile/locations")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(validRequest)))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(MemberErrorCode.OVER_NUMBER_OF_ADDR.getCode()))
+ .andExpect(jsonPath("$.message").value(MemberErrorCode.OVER_NUMBER_OF_ADDR.getMessage()));
+ }
+ }
+ }
+
+
+ @Nested
+ @DisplayName("PATCH /api/my/profile/locations/{memberAddrId} - 대표 주소 변경")
+ class UpdateMainAddress {
+
+ @Nested
+ @DisplayName("성공")
+ class Success {
+
+ @Test
+ @DisplayName("200 - 비대표 주소로 대표 주소를 변경하면 성공한다")
+ void updateMainAddress_success() throws Exception {
+ memberAddrRepository.save(MemberAddrFixture.createMainAddr(member));
+ MemberAddr subAddr = memberAddrRepository.save(MemberAddrFixture.createSubAddr(member));
+ SecurityContextHelper.setAuthentication(member.getId(), member.getNickname());
+
+ mockMvc.perform(patch("/api/my/profile/locations/{memberAddrId}", subAddr.getId()))
+ .andExpect(status().isOk());
+ }
+ }
+
+ @Nested
+ @DisplayName("실패")
+ class Failure {
+
+ @Test
+ @DisplayName("404 - 존재하지 않는 주소 ID로 변경하면 ADDRESS_NOT_FOUND 에러를 반환한다")
+ void addressNotFound() throws Exception {
+ SecurityContextHelper.setAuthentication(member.getId(), member.getNickname());
+
+ mockMvc.perform(patch("/api/my/profile/locations/{memberAddrId}", 999L))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.code").value(MemberErrorCode.ADDRESS_NOT_FOUND.getCode()))
+ .andExpect(jsonPath("$.message").value(MemberErrorCode.ADDRESS_NOT_FOUND.getMessage()));
+ }
+ }
+ }
+
+
+ @Nested
+ @DisplayName("DELETE /api/my/profile/locations/{memberAddrId} - 주소 삭제")
+ class DeleteAddress {
+
+ @Nested
+ @DisplayName("성공")
+ class Success {
+
+ @Test
+ @DisplayName("200 - 비대표 주소를 삭제하면 성공한다")
+ void deleteSubAddress_success() throws Exception {
+ memberAddrRepository.save(MemberAddrFixture.createMainAddr(member));
+ MemberAddr subAddr = memberAddrRepository.save(MemberAddrFixture.createSubAddr(member));
+ SecurityContextHelper.setAuthentication(member.getId(), member.getNickname());
+
+ mockMvc.perform(delete("/api/my/profile/locations/{memberAddrId}", subAddr.getId()))
+ .andExpect(status().isOk());
+ }
+ }
+
+ @Nested
+ @DisplayName("실패")
+ class Failure {
+
+ @Test
+ @DisplayName("400 - 대표 주소는 삭제할 수 없다")
+ void cannotRemoveMainAddress() throws Exception {
+ MemberAddr mainAddr = memberAddrRepository.save(MemberAddrFixture.createMainAddr(member));
+ memberAddrRepository.save(MemberAddrFixture.createSubAddr(member));
+ SecurityContextHelper.setAuthentication(member.getId(), member.getNickname());
+
+ mockMvc.perform(delete("/api/my/profile/locations/{memberAddrId}", mainAddr.getId()))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(MemberErrorCode.CANNOT_REMOVE_MAIN_ADDR.getCode()))
+ .andExpect(jsonPath("$.message").value(MemberErrorCode.CANNOT_REMOVE_MAIN_ADDR.getMessage()));
+ }
+
+ @Test
+ @DisplayName("400 - 주소가 1개뿐일 때 삭제하면 MEMBER_ADDRESS_MINIMUM_REQUIRED 에러를 반환한다")
+ void minimumAddressRequired() throws Exception {
+ // 비대표 주소 1개만 존재: isMain=false 이므로 첫 번째 체크(대표주소 여부)를 통과하고
+ // 두 번째 체크(1개 이하)에서 예외 발생
+ MemberAddr subAddr = memberAddrRepository.save(MemberAddrFixture.createSubAddr(member));
+ SecurityContextHelper.setAuthentication(member.getId(), member.getNickname());
+
+ mockMvc.perform(delete("/api/my/profile/locations/{memberAddrId}", subAddr.getId()))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(MemberErrorCode.MEMBER_ADDRESS_MINIMUM_REQUIRED.getCode()))
+ .andExpect(jsonPath("$.message").value(MemberErrorCode.MEMBER_ADDRESS_MINIMUM_REQUIRED.getMessage()));
+ }
+ }
+ }
+
+
+ @Nested
+ @DisplayName("GET /api/my/location - 현재 위치 조회")
+ class GetNowAddress {
+
+ @Nested
+ @DisplayName("성공")
+ class Success {
+
+ @Test
+ @DisplayName("200 - 대표 주소가 있으면 현재 위치를 반환한다")
+ void getNowAddress_success() throws Exception {
+ memberAddrRepository.save(MemberAddrFixture.createMainAddr(member));
+ SecurityContextHelper.setAuthentication(member.getId(), member.getNickname());
+
+ mockMvc.perform(get("/api/my/location"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.memberAddrId").isNumber())
+ .andExpect(jsonPath("$.data.addr3").value("역삼동"))
+ .andExpect(jsonPath("$.data.streetAddr").value("테헤란로 123"))
+ .andExpect(jsonPath("$.data.buildingName").value("ㅁㅁ빌딩"));
+ }
+ }
+
+ @Nested
+ @DisplayName("실패")
+ class Failure {
+
+ @Test
+ @DisplayName("400 - 대표 주소가 없으면 MAIN_ADDRESS_NULL 에러를 반환한다")
+ void noMainAddress_fail() throws Exception {
+ SecurityContextHelper.setAuthentication(member.getId(), member.getNickname());
+
+ mockMvc.perform(get("/api/my/location"))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(MemberErrorCode.MAIN_ADDRESS_NULL.getCode()))
+ .andExpect(jsonPath("$.message").value(MemberErrorCode.MAIN_ADDRESS_NULL.getMessage()));
+ }
+ }
+ }
+
+ // =====================================================
+ // GET /api/my/profile/locations - 전체 주소 조회
+ // =====================================================
+
+ @Nested
+ @DisplayName("GET /api/my/profile/locations - 전체 주소 조회")
+ class GetAllAddress {
+
+ @Nested
+ @DisplayName("성공")
+ class Success {
+
+ @Test
+ @DisplayName("200 - 전체 주소를 조회하면 대표 주소가 먼저 반환된다")
+ void getAllAddress_mainFirst() throws Exception {
+ memberAddrRepository.save(MemberAddrFixture.createSubAddr(member));
+ memberAddrRepository.save(MemberAddrFixture.createMainAddr(member));
+ SecurityContextHelper.setAuthentication(member.getId(), member.getNickname());
+
+ mockMvc.perform(get("/api/my/profile/locations"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data", hasSize(2)))
+ .andExpect(jsonPath("$.data[0].isMainAddr").value(true))
+ .andExpect(jsonPath("$.data[1].isMainAddr").value(false));
+ }
+
+ @Test
+ @DisplayName("200 - 주소의 모든 필드가 정상 반환된다")
+ void getAllAddress_모든_필드가_정상_반환된다() throws Exception {
+ // given
+ MemberAddr mainAddr = memberAddrRepository.save(MemberAddrFixture.createMainAddr(member));
+ SecurityContextHelper.setAuthentication(member.getId(), member.getNickname());
+
+ // when & then
+ mockMvc.perform(get("/api/my/profile/locations"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data", hasSize(1)))
+ .andExpect(jsonPath("$.data[0].addrId").value(mainAddr.getId()))
+ .andExpect(jsonPath("$.data[0].addr1").value("서울특별시"))
+ .andExpect(jsonPath("$.data[0].addr2").value("강남구"))
+ .andExpect(jsonPath("$.data[0].addr3").value("역삼동"))
+ .andExpect(jsonPath("$.data[0].streetAddr").value("테헤란로 123"))
+ .andExpect(jsonPath("$.data[0].buildingName").value("ㅁㅁ빌딩"))
+ .andExpect(jsonPath("$.data[0].latitude").value(37.5))
+ .andExpect(jsonPath("$.data[0].longitude").value(127.0))
+ .andExpect(jsonPath("$.data[0].isMainAddr").value(true));
+ }
+ }
+ }
+}
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 0e59bc2e0..f74620c03 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
@@ -9,23 +9,44 @@
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
+import umc.cockple.demo.domain.chat.domain.ChatRoomMember;
import umc.cockple.demo.domain.chat.repository.ChatRoomMemberRepository;
-import umc.cockple.demo.domain.image.service.ImageService;
+import umc.cockple.demo.support.fixture.ChatFixture;
+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.domain.MemberParty;
+import umc.cockple.demo.domain.member.domain.ProfileImg;
+import umc.cockple.demo.domain.member.dto.MemberDetailInfoRequestDTO;
+import umc.cockple.demo.domain.member.dto.UpdateProfileRequestDTO;
+import umc.cockple.demo.domain.member.enums.MemberPartyStatus;
+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.*;
import umc.cockple.demo.global.enums.Gender;
+import umc.cockple.demo.global.enums.Keyword;
import umc.cockple.demo.global.enums.Level;
+import umc.cockple.demo.global.enums.Role;
import umc.cockple.demo.global.oauth2.service.KakaoOauthService;
+import umc.cockple.demo.support.fixture.MemberAddrFixture;
import umc.cockple.demo.support.fixture.MemberFixture;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+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.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
import static org.mockito.Mockito.never;
+import static umc.cockple.demo.domain.member.dto.CreateMemberAddrDTO.*;
+
@ExtendWith(MockitoExtension.class)
@DisplayName("MemberCommandService")
class MemberCommandServiceTest {
@@ -39,17 +60,538 @@ class MemberCommandServiceTest {
@Mock private MemberKeywordRepository memberKeywordRepository;
@Mock private MemberAddrRepository memberAddrRepository;
@Mock private ChatRoomMemberRepository chatRoomMemberRepository;
+ @Mock private FileService fileService;
@Mock private KakaoOauthService kakaoOauthService;
- @Mock private ImageService imageService;
private Member normalMember;
@BeforeEach
void setUp() {
- normalMember = MemberFixture.createMember("일반멤버", Gender.MALE, Level.C, 9001L);
+ normalMember = MemberFixture.createMember("와나", Gender.MALE, Level.C, 9001L);
ReflectionTestUtils.setField(normalMember, "id", 1L);
}
+ @Nested
+ @DisplayName("registerMemberDetailInfo")
+ class RegisterMemberDetailInfo {
+
+ private MemberDetailInfoRequestDTO requestWithImg;
+ private MemberDetailInfoRequestDTO requestWithoutImg;
+
+ @BeforeEach
+ void setUp() {
+ requestWithImg = MemberDetailInfoRequestDTO.builder()
+ .memberName("강와나")
+ .gender(Gender.MALE)
+ .birth(LocalDate.of(2002, 4, 2))
+ .level(Level.A)
+ .imgKey("profile/test-key.jpg")
+ .keywords(List.of(Keyword.FRIENDSHIP, Keyword.FREE))
+ .build();
+
+ requestWithoutImg = MemberDetailInfoRequestDTO.builder()
+ .memberName("강와나")
+ .gender(Gender.MALE)
+ .birth(LocalDate.of(2002, 4, 2))
+ .level(Level.A)
+ .imgKey(null)
+ .keywords(List.of(Keyword.FRIENDSHIP))
+ .build();
+ }
+
+ @Nested
+ @DisplayName("성공")
+ class Success {
+
+ @Test
+ @DisplayName("imgKey가_있으면_ProfileImg와_함께_회원정보가_업데이트된다")
+ void imgKey가_있으면_ProfileImg와_함께_회원정보가_업데이트된다() {
+ // given
+ given(memberRepository.findById(normalMember.getId()))
+ .willReturn(Optional.of(normalMember));
+
+ // when
+ memberCommandService.memberDetailInfo(normalMember.getId(), requestWithImg);
+
+ // then
+ then(memberKeywordRepository).should().saveAll(any());
+
+ assertThat(normalMember.getMemberName()).isEqualTo("강와나");
+ assertThat(normalMember.getGender()).isEqualTo(Gender.MALE);
+ assertThat(normalMember.getBirth()).isEqualTo(LocalDate.of(2002, 4, 2));
+ assertThat(normalMember.getLevel()).isEqualTo(Level.A);
+ assertThat(normalMember.getProfileImg()).isNotNull();
+ assertThat(normalMember.getProfileImg().getImgKey()).isEqualTo("profile/test-key.jpg");
+ }
+
+ @Test
+ @DisplayName("imgKey가_없으면_ProfileImg_없이_회원정보가_업데이트된다")
+ void imgKey가_없으면_ProfileImg_없이_회원정보가_업데이트된다() {
+ // given
+ given(memberRepository.findById(normalMember.getId()))
+ .willReturn(Optional.of(normalMember));
+
+ // when
+ memberCommandService.memberDetailInfo(normalMember.getId(), requestWithoutImg);
+
+ // then
+ then(memberKeywordRepository).should().saveAll(any());
+
+ assertThat(normalMember.getMemberName()).isEqualTo("강와나");
+ assertThat(normalMember.getLevel()).isEqualTo(Level.A);
+ assertThat(normalMember.getProfileImg()).isNull();
+ }
+
+ @Test
+ @DisplayName("keywords가_정상적으로_저장된다")
+ void keywords가_정상적으로_저장된다() {
+ // given
+ given(memberRepository.findById(normalMember.getId()))
+ .willReturn(Optional.of(normalMember));
+
+ // when
+ memberCommandService.memberDetailInfo(normalMember.getId(), requestWithImg);
+
+ // then
+ // saveAll 호출 시 keywords 개수가 request와 동일한지 검증
+ assertThat(normalMember.getKeywords()).hasSize(requestWithImg.keywords().size());
+ }
+ }
+
+ @Nested
+ @DisplayName("실패")
+ class Failure {
+
+ @Test
+ @DisplayName("존재하지_않는_회원이면_MEMBER_NOT_FOUND_예외를_던진다")
+ void 존재하지_않는_회원이면_MEMBER_NOT_FOUND_예외를_던진다() {
+ // given
+ given(memberRepository.findById(999L))
+ .willReturn(Optional.empty());
+
+ // when & then
+ assertThatThrownBy(() ->
+ memberCommandService.memberDetailInfo(999L, requestWithImg))
+ .isInstanceOf(MemberException.class)
+ .hasFieldOrPropertyWithValue("code", MemberErrorCode.MEMBER_NOT_FOUND);
+ }
+ }
+ }
+
+ @Nested
+ @DisplayName("updateProfile")
+ class UpdateProfile {
+
+ private UpdateProfileRequestDTO requestWithImg;
+ private UpdateProfileRequestDTO requestWithoutImg;
+
+ @BeforeEach
+ void setUp() {
+ requestWithImg = new UpdateProfileRequestDTO(
+ "강와나", LocalDate.of(2002, 4, 2), Level.A,
+ List.of(Keyword.FRIENDSHIP, Keyword.FREE), "profile/new-key.jpg");
+
+ requestWithoutImg = new UpdateProfileRequestDTO(
+ "강와나", LocalDate.of(2002, 4, 2), Level.A,
+ List.of(Keyword.FRIENDSHIP), null);
+ }
+
+ @Nested
+ @DisplayName("성공")
+ class Success {
+
+ @Test
+ @DisplayName("imgKey가_없으면_이미지_없이_프로필이_업데이트된다")
+ void imgKey가_없으면_이미지_없이_프로필이_업데이트된다() {
+ // given
+ given(memberRepository.findMemberWithProfileById(normalMember.getId()))
+ .willReturn(Optional.of(normalMember));
+
+ // when
+ memberCommandService.updateProfile(requestWithoutImg, normalMember.getId());
+
+ // then
+ then(memberKeywordRepository).should().deleteAllByMember(normalMember);
+ then(memberKeywordRepository).should().saveAll(any());
+ assertThat(normalMember.getMemberName()).isEqualTo("강와나");
+ assertThat(normalMember.getLevel()).isEqualTo(Level.A);
+ }
+
+ @Test
+ @DisplayName("기존_이미지가_없고_imgKey가_있으면_새_ProfileImg를_생성해_업데이트된다")
+ void 기존_이미지가_없고_imgKey가_있으면_새_ProfileImg를_생성해_업데이트된다() {
+ // given
+ given(memberRepository.findMemberWithProfileById(normalMember.getId()))
+ .willReturn(Optional.of(normalMember));
+
+ // when
+ memberCommandService.updateProfile(requestWithImg, normalMember.getId());
+
+ // then
+ assertThat(normalMember.getProfileImg()).isNotNull();
+ assertThat(normalMember.getProfileImg().getImgKey()).isEqualTo("profile/new-key.jpg");
+ }
+
+ @Test
+ @DisplayName("기존_이미지가_있고_imgKey가_다르면_기존_이미지를_삭제하고_업데이트된다")
+ void 기존_이미지가_있고_imgKey가_다르면_기존_이미지를_삭제하고_업데이트된다() {
+ // given
+ ProfileImg existingProfile = ProfileImg.builder()
+ .member(normalMember)
+ .imgKey("profile/old-key.jpg")
+ .build();
+ ReflectionTestUtils.setField(normalMember, "profileImg", existingProfile);
+
+ given(memberRepository.findMemberWithProfileById(normalMember.getId()))
+ .willReturn(Optional.of(normalMember));
+
+ // when
+ memberCommandService.updateProfile(requestWithImg, normalMember.getId());
+
+ // then
+ then(fileService).should().delete("profile/old-key.jpg");
+ }
+
+ @Test
+ @DisplayName("기존_이미지가_있고_imgKey가_같으면_삭제_없이_업데이트된다")
+ void 기존_이미지가_있고_imgKey가_같으면_삭제_없이_업데이트된다() {
+ // given
+ UpdateProfileRequestDTO sameImgRequest = new UpdateProfileRequestDTO(
+ "강와나", LocalDate.of(2002, 4, 2), Level.A,
+ List.of(Keyword.FRIENDSHIP), "profile/same-key.jpg");
+
+ ProfileImg existingProfile = ProfileImg.builder()
+ .member(normalMember)
+ .imgKey("profile/same-key.jpg")
+ .build();
+ ReflectionTestUtils.setField(normalMember, "profileImg", existingProfile);
+
+ given(memberRepository.findMemberWithProfileById(normalMember.getId()))
+ .willReturn(Optional.of(normalMember));
+
+ // when
+ memberCommandService.updateProfile(sameImgRequest, normalMember.getId());
+
+ // then
+ then(fileService).should(never()).delete(any());
+ }
+
+ @Test
+ @DisplayName("기존_키워드를_삭제하고_새_키워드를_저장한다")
+ void 기존_키워드를_삭제하고_새_키워드를_저장한다() {
+ // given
+ given(memberRepository.findMemberWithProfileById(normalMember.getId()))
+ .willReturn(Optional.of(normalMember));
+
+ // when
+ memberCommandService.updateProfile(requestWithImg, normalMember.getId());
+
+ // then
+ then(memberKeywordRepository).should().deleteAllByMember(normalMember);
+ then(memberKeywordRepository).should().saveAll(any());
+ assertThat(normalMember.getKeywords()).hasSize(requestWithImg.keywords().size());
+ }
+
+ @Test
+ @DisplayName("프로필_수정_시_DIRECT_채팅방_상대방의_displayName이_업데이트된다")
+ void 프로필_수정_시_DIRECT_채팅방_상대방의_displayName이_업데이트된다() {
+ // given
+ ChatRoomMember counterPartCrm = ChatFixture.createChatRoomMemberWithDisplayName("홍길동");
+
+ given(memberRepository.findMemberWithProfileById(normalMember.getId()))
+ .willReturn(Optional.of(normalMember));
+ given(chatRoomMemberRepository.findDirectChatCounterParts(normalMember.getId()))
+ .willReturn(List.of(counterPartCrm));
+
+ // when
+ memberCommandService.updateProfile(requestWithoutImg, normalMember.getId());
+
+ // then
+ assertThat(counterPartCrm.getDisplayName()).isEqualTo(requestWithoutImg.memberName());
+ }
+ }
+
+ @Nested
+ @DisplayName("실패")
+ class Failure {
+
+ @Test
+ @DisplayName("존재하지_않는_회원이면_MEMBER_NOT_FOUND_예외를_던진다")
+ void 존재하지_않는_회원이면_MEMBER_NOT_FOUND_예외를_던진다() {
+ // given
+ given(memberRepository.findMemberWithProfileById(999L))
+ .willReturn(Optional.empty());
+
+ // when & then
+ assertThatThrownBy(() -> memberCommandService.updateProfile(requestWithImg, 999L))
+ .isInstanceOf(MemberException.class)
+ .hasFieldOrPropertyWithValue("code", MemberErrorCode.MEMBER_NOT_FOUND);
+ }
+ }
+ }
+
+ @Nested
+ @DisplayName("addMemberNewAddr")
+ class AddMemberNewAddr {
+
+ private CreateMemberAddrRequestDTO requestDto;
+
+ @BeforeEach
+ void setUp() {
+ requestDto = new CreateMemberAddrRequestDTO(
+ "서울특별시", "강남구", "역삼동",
+ "테헤란로 123", "테스트빌딩",
+ 37.5, 127.0);
+ }
+
+ @Nested
+ @DisplayName("성공")
+ class Success {
+
+ @Test
+ @DisplayName("정상적으로_주소를_등록하면_새_주소_ID를_반환한다")
+ void 정상적으로_주소를_등록하면_새_주소_ID를_반환한다() {
+ // given
+ MemberAddr savedAddr = MemberAddrFixture.createSeoulAddr(normalMember, true);
+ ReflectionTestUtils.setField(savedAddr, "id", 10L);
+
+ given(memberRepository.findById(normalMember.getId()))
+ .willReturn(Optional.of(normalMember));
+ given(memberAddrRepository.save(any())).willReturn(savedAddr);
+
+ // when
+ CreateMemberAddrResponseDTO response =
+ memberCommandService.addMemberNewAddr(requestDto, normalMember.getId());
+
+ // then
+ assertThat(response.memberAddrId()).isEqualTo(10L);
+ }
+
+ @Test
+ @DisplayName("기존_대표주소가_있으면_대표주소가_false로_해제된다")
+ void 기존_대표주소가_있으면_대표주소가_false로_해제된다() {
+ // given
+ MemberAddr existingMainAddr = MemberAddrFixture.createBusanAddr(normalMember, true);
+ normalMember.getAddresses().add(existingMainAddr);
+
+ MemberAddr newAddr = MemberAddrFixture.createSeoulAddr(normalMember, true);
+ ReflectionTestUtils.setField(newAddr, "id", 11L);
+
+ given(memberRepository.findById(normalMember.getId()))
+ .willReturn(Optional.of(normalMember));
+ given(memberAddrRepository.save(any())).willReturn(newAddr);
+
+ // when
+ memberCommandService.addMemberNewAddr(requestDto, normalMember.getId());
+
+ // then
+ assertThat(existingMainAddr.getIsMain()).isFalse();
+ }
+ }
+
+ @Nested
+ @DisplayName("실패")
+ class Failure {
+
+ @Test
+ @DisplayName("존재하지_않는_회원이면_MEMBER_NOT_FOUND_예외를_던진다")
+ void 존재하지_않는_회원이면_MEMBER_NOT_FOUND_예외를_던진다() {
+ // given
+ given(memberRepository.findById(999L)).willReturn(Optional.empty());
+
+ // when & then
+ assertThatThrownBy(() -> memberCommandService.addMemberNewAddr(requestDto, 999L))
+ .isInstanceOf(MemberException.class)
+ .hasFieldOrPropertyWithValue("code", MemberErrorCode.MEMBER_NOT_FOUND);
+ }
+
+ @Test
+ @DisplayName("중복_주소이면_DUPLICATE_ADDRESS_예외를_던진다")
+ void 중복_주소이면_DUPLICATE_ADDRESS_예외를_던진다() {
+ // given
+ // requestDto(서울/강남/역삼/테헤란로 123/테스트빌딩/37.5/127.0)와 동일한 주소를 미리 등록
+ MemberAddr existingAddr = MemberAddrFixture.createSeoulAddr(normalMember, true);
+ normalMember.getAddresses().add(existingAddr);
+
+ given(memberRepository.findById(normalMember.getId()))
+ .willReturn(Optional.of(normalMember));
+
+ // when & then
+ assertThatThrownBy(() ->
+ memberCommandService.addMemberNewAddr(requestDto, normalMember.getId()))
+ .isInstanceOf(MemberException.class)
+ .hasFieldOrPropertyWithValue("code", MemberErrorCode.DUPLICATE_ADDRESS);
+ }
+
+ @Test
+ @DisplayName("주소가_5개_이상이면_OVER_NUMBER_OF_ADDR_예외를_던진다")
+ void 주소가_5개_이상이면_OVER_NUMBER_OF_ADDR_예외를_던진다() {
+ // given
+ for (int i = 0; i < 5; i++) {
+ normalMember.getAddresses().add(
+ MemberAddrFixture.createAddrWithIndex(normalMember, i, i == 0));
+ }
+
+ given(memberRepository.findById(normalMember.getId()))
+ .willReturn(Optional.of(normalMember));
+
+ // when & then
+ assertThatThrownBy(() ->
+ memberCommandService.addMemberNewAddr(requestDto, normalMember.getId()))
+ .isInstanceOf(MemberException.class)
+ .hasFieldOrPropertyWithValue("code", MemberErrorCode.OVER_NUMBER_OF_ADDR);
+ }
+ }
+ }
+
+ @Nested
+ @DisplayName("updateMainAddr")
+ class UpdateMainAddr {
+
+ @Nested
+ @DisplayName("성공")
+ class Success {
+
+ @Test
+ @DisplayName("기존_대표주소가_해제되고_새_대표주소가_설정된다")
+ void 기존_대표주소가_해제되고_새_대표주소가_설정된다() {
+ // given
+ MemberAddr oldMainAddr = MemberAddrFixture.createBusanAddr(normalMember, true);
+ normalMember.getAddresses().add(oldMainAddr);
+
+ MemberAddr newMainAddr = MemberAddrFixture.createSeoulAddr(normalMember, false);
+ ReflectionTestUtils.setField(newMainAddr, "id", 20L);
+
+ given(memberRepository.findById(normalMember.getId()))
+ .willReturn(Optional.of(normalMember));
+ given(memberAddrRepository.findById(20L))
+ .willReturn(Optional.of(newMainAddr));
+
+ // when
+ memberCommandService.updateMainAddr(normalMember.getId(), 20L);
+
+ // then
+ assertThat(oldMainAddr.getIsMain()).isFalse();
+ assertThat(newMainAddr.getIsMain()).isTrue();
+ }
+ }
+
+ @Nested
+ @DisplayName("실패")
+ class Failure {
+
+ @Test
+ @DisplayName("존재하지_않는_주소이면_ADDRESS_NOT_FOUND_예외를_던진다")
+ void 존재하지_않는_주소이면_ADDRESS_NOT_FOUND_예외를_던진다() {
+ // given
+ given(memberRepository.findById(normalMember.getId()))
+ .willReturn(Optional.of(normalMember));
+ given(memberAddrRepository.findById(999L)).willReturn(Optional.empty());
+
+ // when & then
+ assertThatThrownBy(() ->
+ memberCommandService.updateMainAddr(normalMember.getId(), 999L))
+ .isInstanceOf(MemberException.class)
+ .hasFieldOrPropertyWithValue("code", MemberErrorCode.ADDRESS_NOT_FOUND);
+ }
+ }
+ }
+
+ // =====================================================================
+ // deleteMemberAddr
+ // =====================================================================
+
+ @Nested
+ @DisplayName("deleteMemberAddr")
+ class DeleteMemberAddr {
+
+ @Nested
+ @DisplayName("성공")
+ class Success {
+
+ @Test
+ @DisplayName("대표주소가_아닌_주소를_삭제하면_정상_삭제된다")
+ void 대표주소가_아닌_주소를_삭제하면_정상_삭제된다() {
+ // given
+ MemberAddr mainAddr = MemberAddrFixture.createMainAddr(normalMember);
+ MemberAddr subAddr = MemberAddrFixture.createSubAddr(normalMember);
+ ReflectionTestUtils.setField(subAddr, "id", 30L);
+ normalMember.getAddresses().add(mainAddr);
+ normalMember.getAddresses().add(subAddr);
+
+ given(memberRepository.findById(normalMember.getId()))
+ .willReturn(Optional.of(normalMember));
+ given(memberAddrRepository.findById(30L)).willReturn(Optional.of(subAddr));
+
+ // when
+ memberCommandService.deleteMemberAddr(normalMember.getId(), 30L);
+
+ // then
+ then(memberAddrRepository).should().deleteById(30L);
+ assertThat(normalMember.getAddresses()).doesNotContain(subAddr);
+ }
+ }
+
+ @Nested
+ @DisplayName("실패")
+ class Failure {
+
+
+ @Test
+ @DisplayName("존재하지_않는_주소이면_ADDRESS_NOT_FOUND_예외를_던진다")
+ void 존재하지_않는_주소이면_ADDRESS_NOT_FOUND_예외를_던진다() {
+ // given
+ given(memberRepository.findById(normalMember.getId()))
+ .willReturn(Optional.of(normalMember));
+ given(memberAddrRepository.findById(999L)).willReturn(Optional.empty());
+
+ // when & then
+ assertThatThrownBy(() ->
+ memberCommandService.deleteMemberAddr(normalMember.getId(), 999L))
+ .isInstanceOf(MemberException.class)
+ .hasFieldOrPropertyWithValue("code", MemberErrorCode.ADDRESS_NOT_FOUND);
+ }
+
+ @Test
+ @DisplayName("대표주소를_삭제하면_CANNOT_REMOVE_MAIN_ADDR_예외를_던진다")
+ void 대표주소를_삭제하면_CANNOT_REMOVE_MAIN_ADDR_예외를_던진다() {
+ // given
+ MemberAddr mainAddr = MemberAddrFixture.createMainAddr(normalMember);
+ ReflectionTestUtils.setField(mainAddr, "id", 31L);
+ normalMember.getAddresses().add(mainAddr);
+
+ given(memberRepository.findById(normalMember.getId()))
+ .willReturn(Optional.of(normalMember));
+ given(memberAddrRepository.findById(31L)).willReturn(Optional.of(mainAddr));
+
+ // when & then
+ assertThatThrownBy(() ->
+ memberCommandService.deleteMemberAddr(normalMember.getId(), 31L))
+ .isInstanceOf(MemberException.class)
+ .hasFieldOrPropertyWithValue("code", MemberErrorCode.CANNOT_REMOVE_MAIN_ADDR);
+ }
+
+ @Test
+ @DisplayName("주소가_1개이면_MEMBER_ADDRESS_MINIMUM_REQUIRED_예외를_던진다")
+ void 주소가_1개이면_MEMBER_ADDRESS_MINIMUM_REQUIRED_예외를_던진다() {
+ // given
+ MemberAddr onlySubAddr = MemberAddrFixture.createSubAddr(normalMember);
+ ReflectionTestUtils.setField(onlySubAddr, "id", 32L);
+ normalMember.getAddresses().add(onlySubAddr);
+
+ given(memberRepository.findById(normalMember.getId()))
+ .willReturn(Optional.of(normalMember));
+ given(memberAddrRepository.findById(32L)).willReturn(Optional.of(onlySubAddr));
+
+ // when & then
+ assertThatThrownBy(() ->
+ memberCommandService.deleteMemberAddr(normalMember.getId(), 32L))
+ .isInstanceOf(MemberException.class)
+ .hasFieldOrPropertyWithValue("code", MemberErrorCode.MEMBER_ADDRESS_MINIMUM_REQUIRED);
+ }
+ }
+ }
+
@Nested
@DisplayName("withdrawMember")
class WithdrawMember {
@@ -69,5 +611,153 @@ class WithdrawMember {
then(memberExerciseRepository).should(never())
.deleteAll();
}
+
+ @Nested
+ @DisplayName("성공")
+ class Success {
+
+ @Test
+ @DisplayName("미래_운동만_삭제되고_모임과_키워드도_삭제된다")
+ void 미래_운동만_삭제되고_모임과_키워드도_삭제된다() {
+ // given
+ given(memberRepository.findById(normalMember.getId()))
+ .willReturn(Optional.of(normalMember));
+
+ // when
+ memberCommandService.withdrawMember(normalMember.getId());
+
+ // then
+ then(memberExerciseRepository).should()
+ .deleteFutureExercisesByMember(eq(normalMember), any(), any());
+ then(memberExerciseRepository).should(never()).deleteAll();
+ then(memberPartyRepository).should().deleteAllByMember(normalMember);
+ then(memberKeywordRepository).should().deleteAllByMember(normalMember);
+ }
+
+ @Test
+ @DisplayName("탈퇴_후_회원_상태가_INACTIVE가_되고_refreshToken이_null이_된다")
+ void 탈퇴_후_회원_상태가_INACTIVE가_되고_refreshToken이_null이_된다() {
+ // given
+ normalMember.setRefreshToken("existing-refresh-token");
+ given(memberRepository.findById(normalMember.getId()))
+ .willReturn(Optional.of(normalMember));
+
+ // when
+ memberCommandService.withdrawMember(normalMember.getId());
+
+ // then
+ assertThat(normalMember.getIsActive()).isEqualTo(MemberStatus.INACTIVE);
+ assertThat(normalMember.getRefreshToken()).isNull();
+ }
+
+ @Test
+ @DisplayName("카카오_연결_끊기가_호출된다")
+ void 카카오_연결_끊기가_호출된다() {
+ // given
+ given(memberRepository.findById(normalMember.getId()))
+ .willReturn(Optional.of(normalMember));
+
+ // when
+ memberCommandService.withdrawMember(normalMember.getId());
+
+ // then
+ then(kakaoOauthService).should().unlinkAccess(normalMember);
+ }
+ }
+
+ @Nested
+ @DisplayName("실패")
+ class Failure {
+
+ @Test
+ @DisplayName("존재하지_않는_회원이면_MEMBER_NOT_FOUND_예외를_던진다")
+ void 존재하지_않는_회원이면_MEMBER_NOT_FOUND_예외를_던진다() {
+ // given
+ given(memberRepository.findById(999L))
+ .willReturn(Optional.empty());
+
+ // when & then
+ assertThatThrownBy(() -> memberCommandService.withdrawMember(999L))
+ .isInstanceOf(MemberException.class)
+ .hasFieldOrPropertyWithValue("code", MemberErrorCode.MEMBER_NOT_FOUND);
+ }
+
+ @Test
+ @DisplayName("이미_탈퇴한_회원이면_ALREADY_WITHDRAW_예외를_던진다")
+ void 이미_탈퇴한_회원이면_ALREADY_WITHDRAW_예외를_던진다() {
+ // given
+ Member withdrawnMember = MemberFixture.createWithdrawnMember("탈퇴회원", "탈퇴닉", 9002L);
+ ReflectionTestUtils.setField(withdrawnMember, "id", 2L);
+
+ given(memberRepository.findById(withdrawnMember.getId()))
+ .willReturn(Optional.of(withdrawnMember));
+
+ // when & then
+ assertThatThrownBy(() -> memberCommandService.withdrawMember(withdrawnMember.getId()))
+ .isInstanceOf(MemberException.class)
+ .hasFieldOrPropertyWithValue("code", MemberErrorCode.ALREADY_WITHDRAW);
+ }
+
+ @Test
+ @DisplayName("활성_모임의_모임장이면_MANAGER_CANNOT_LEAVE_예외를_던진다")
+ void 활성_모임의_모임장이면_MANAGER_CANNOT_LEAVE_예외를_던진다() {
+ // given
+ MemberParty leaderParty = MemberParty.builder()
+ .role(Role.PARTY_MANAGER)
+ .status(MemberPartyStatus.ACTIVE)
+ .joinedAt(LocalDateTime.now())
+ .build();
+ normalMember.getMemberParties().add(leaderParty);
+
+ given(memberRepository.findById(normalMember.getId()))
+ .willReturn(Optional.of(normalMember));
+
+ // when & then
+ assertThatThrownBy(() -> memberCommandService.withdrawMember(normalMember.getId()))
+ .isInstanceOf(MemberException.class)
+ .hasFieldOrPropertyWithValue("code", MemberErrorCode.MANAGER_CANNOT_LEAVE);
+ }
+
+ @Test
+ @DisplayName("활성_모임의_부모임장이면_SUBMANAGER_CANNOT_LEAVE_예외를_던진다")
+ void 활성_모임의_부모임장이면_SUBMANAGER_CANNOT_LEAVE_예외를_던진다() {
+ // given
+ MemberParty subManagerParty = MemberParty.builder()
+ .role(Role.PARTY_SUBMANAGER)
+ .status(MemberPartyStatus.ACTIVE)
+ .joinedAt(LocalDateTime.now())
+ .build();
+ normalMember.getMemberParties().add(subManagerParty);
+
+ given(memberRepository.findById(normalMember.getId()))
+ .willReturn(Optional.of(normalMember));
+
+ // when & then
+ assertThatThrownBy(() -> memberCommandService.withdrawMember(normalMember.getId()))
+ .isInstanceOf(MemberException.class)
+ .hasFieldOrPropertyWithValue("code", MemberErrorCode.SUBMANAGER_CANNOT_LEAVE);
+ }
+
+ @Test
+ @DisplayName("비활성_모임의_모임장이면_탈퇴가_가능하다")
+ void 비활성_모임의_모임장이면_탈퇴가_가능하다() {
+ // given: BANNED 상태의 모임이라면 탈퇴 검증을 통과해야 한다
+ MemberParty bannedParty = MemberParty.builder()
+ .role(Role.PARTY_MANAGER)
+ .status(MemberPartyStatus.BANNED)
+ .joinedAt(LocalDateTime.now())
+ .build();
+ normalMember.getMemberParties().add(bannedParty);
+
+ given(memberRepository.findById(normalMember.getId()))
+ .willReturn(Optional.of(normalMember));
+
+ // when
+ memberCommandService.withdrawMember(normalMember.getId());
+
+ // then: 예외 없이 탈퇴 처리됨
+ assertThat(normalMember.getIsActive()).isEqualTo(MemberStatus.INACTIVE);
+ }
+ }
}
}
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
new file mode 100644
index 000000000..9c1eb0a16
--- /dev/null
+++ b/src/test/java/umc/cockple/demo/domain/member/service/MemberQueryServiceTest.java
@@ -0,0 +1,344 @@
+package umc.cockple.demo.domain.member.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.contest.domain.Contest;
+import umc.cockple.demo.domain.contest.enums.MedalType;
+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.domain.MemberExercise;
+import umc.cockple.demo.domain.member.domain.ProfileImg;
+import umc.cockple.demo.domain.member.dto.GetAllAddressResponseDTO;
+import umc.cockple.demo.domain.member.dto.GetMyProfileResponseDTO;
+import umc.cockple.demo.domain.member.dto.GetNowAddressResponseDTO;
+import umc.cockple.demo.domain.member.dto.GetProfileResponseDTO;
+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.enums.Gender;
+import umc.cockple.demo.global.enums.Keyword;
+import umc.cockple.demo.global.enums.Level;
+import umc.cockple.demo.support.fixture.MemberAddrFixture;
+import umc.cockple.demo.support.fixture.MemberFixture;
+
+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.BDDMockito.given;
+import static org.mockito.BDDMockito.then;
+import static org.mockito.Mockito.never;
+
+@ExtendWith(MockitoExtension.class)
+@DisplayName("MemberQueryService")
+class MemberQueryServiceTest {
+
+ @InjectMocks
+ private MemberQueryService memberQueryService;
+
+ @Mock private MemberRepository memberRepository;
+ @Mock private FileService fileService;
+
+ private Member member;
+
+ @BeforeEach
+ void setUp() {
+ member = MemberFixture.createMember("강와나", Gender.FEMALE, Level.A, 1001L);
+ ReflectionTestUtils.setField(member, "id", 1L);
+ }
+
+ @Nested
+ @DisplayName("getProfile")
+ class GetProfile {
+
+ @Nested
+ @DisplayName("성공")
+ class Success {
+
+ @Test
+ @DisplayName("profileImg가_있으면_fileService로_url을_변환해서_반환한다")
+ void profileImg가_있으면_fileService로_url을_변환해서_반환한다() {
+ // given
+ ProfileImg profileImg = ProfileImg.builder()
+ .imgKey("profile/test-key.jpg")
+ .build();
+ ReflectionTestUtils.setField(member, "profileImg", profileImg);
+
+ given(memberRepository.findById(member.getId())).willReturn(Optional.of(member));
+ given(fileService.getUrlFromKey("profile/test-key.jpg"))
+ .willReturn("https://cdn.example.com/profile/test-key.jpg");
+
+ // when
+ GetProfileResponseDTO response = memberQueryService.getProfile(member.getId());
+
+ // then
+ assertThat(response.profileImgUrl()).isEqualTo("https://cdn.example.com/profile/test-key.jpg");
+ then(fileService).should().getUrlFromKey("profile/test-key.jpg");
+ }
+
+ @Test
+ @DisplayName("profileImg가_없으면_imgUrl이_null로_반환된다")
+ void profileImg가_없으면_imgUrl이_null로_반환된다() {
+ // given
+ given(memberRepository.findById(member.getId())).willReturn(Optional.of(member));
+
+ // when
+ GetProfileResponseDTO response = memberQueryService.getProfile(member.getId());
+
+ // then
+ assertThat(response.profileImgUrl()).isNull();
+ then(fileService).should(never()).getUrlFromKey(org.mockito.ArgumentMatchers.any());
+ }
+
+ @Test
+ @DisplayName("금_은_동_메달_개수가_올바르게_집계된다")
+ 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));
+
+ // when
+ GetProfileResponseDTO response = memberQueryService.getProfile(member.getId());
+
+ // then
+ assertThat(response.myGoldMedalCnt()).isEqualTo(2);
+ assertThat(response.mySilverMedalCnt()).isEqualTo(1);
+ assertThat(response.myBronzeMedalCnt()).isEqualTo(1);
+ }
+
+ @Test
+ @DisplayName("참여한_모임_수가_올바르게_반환된다")
+ 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));
+
+ // when
+ GetProfileResponseDTO response = memberQueryService.getProfile(member.getId());
+
+ // then
+ assertThat(response.myPartyCnt()).isEqualTo(2);
+ }
+ }
+
+ @Nested
+ @DisplayName("실패")
+ class Failure {
+
+ @Test
+ @DisplayName("존재하지_않는_회원이면_MEMBER_NOT_FOUND_예외를_던진다")
+ void 존재하지_않는_회원이면_MEMBER_NOT_FOUND_예외를_던진다() {
+ // given
+ given(memberRepository.findById(999L)).willReturn(Optional.empty());
+
+ // when & then
+ assertThatThrownBy(() -> memberQueryService.getProfile(999L))
+ .isInstanceOf(MemberException.class)
+ .hasFieldOrPropertyWithValue("code", MemberErrorCode.MEMBER_NOT_FOUND);
+ }
+ }
+ }
+
+
+ @Nested
+ @DisplayName("getMyProfile")
+ class GetMyProfile {
+
+ @Nested
+ @DisplayName("성공")
+ class Success {
+
+ @Test
+ @DisplayName("대표주소_운동횟수_키워드가_포함된_내_프로필이_반환된다")
+ void 대표주소_운동횟수_키워드가_포함된_내_프로필이_반환된다() {
+ // given
+ 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)
+ .keyword(Keyword.FRIENDSHIP)
+ .build();
+ member.getKeywords().add(keyword);
+
+ given(memberRepository.findById(member.getId())).willReturn(Optional.of(member));
+
+ // when
+ GetMyProfileResponseDTO response = memberQueryService.getMyProfile(member.getId());
+
+ // then
+ assertThat(response.addr3()).isEqualTo("역삼동");
+ assertThat(response.myExerciseCnt()).isEqualTo(1);
+ assertThat(response.keywords()).containsExactly(Keyword.FRIENDSHIP);
+ }
+ }
+ }
+
+
+ @Nested
+ @DisplayName("getNowAddress")
+ class GetNowAddress {
+
+ @Nested
+ @DisplayName("성공")
+ class Success {
+
+ @Test
+ @DisplayName("대표주소를_반환한다")
+ void 대표주소를_반환한다() {
+ // given
+ MemberAddr mainAddr = MemberAddrFixture.createAddr(member, "역삼동", "서울특별시 강남구 테헤란로 1", true);
+ member.getAddresses().add(mainAddr);
+
+ given(memberRepository.findById(member.getId())).willReturn(Optional.of(member));
+
+ // when
+ GetNowAddressResponseDTO response = memberQueryService.getNowAddress(member.getId());
+
+ // then
+ assertThat(response.addr3()).isEqualTo("역삼동");
+ assertThat(response.streetAddr()).isEqualTo("서울특별시 강남구 테헤란로 1");
+ }
+ }
+
+ @Nested
+ @DisplayName("실패")
+ class Failure {
+
+ @Test
+ @DisplayName("존재하지_않는_회원이면_MEMBER_NOT_FOUND_예외를_던진다")
+ void 존재하지_않는_회원이면_MEMBER_NOT_FOUND_예외를_던진다() {
+ // given
+ given(memberRepository.findById(999L)).willReturn(Optional.empty());
+
+ // when & then
+ assertThatThrownBy(() -> memberQueryService.getNowAddress(999L))
+ .isInstanceOf(MemberException.class)
+ .hasFieldOrPropertyWithValue("code", MemberErrorCode.MEMBER_NOT_FOUND);
+ }
+
+ @Test
+ @DisplayName("대표주소가_없으면_MAIN_ADDRESS_NULL_예외를_던진다")
+ void 대표주소가_없으면_MAIN_ADDRESS_NULL_예외를_던진다() {
+ // given: isMain=false인 주소만 존재
+ MemberAddr nonMainAddr = MemberAddrFixture.createAddr(member, "삼성동", "서울특별시 강남구 테헤란로 1", false);
+ member.getAddresses().add(nonMainAddr);
+
+ given(memberRepository.findById(member.getId())).willReturn(Optional.of(member));
+
+ // when & then
+ assertThatThrownBy(() -> memberQueryService.getNowAddress(member.getId()))
+ .isInstanceOf(MemberException.class)
+ .hasFieldOrPropertyWithValue("code", MemberErrorCode.MAIN_ADDRESS_NULL);
+ }
+ }
+ }
+
+
+ @Nested
+ @DisplayName("getAllAddress")
+ class GetAllAddress {
+
+ @Nested
+ @DisplayName("성공")
+ class Success {
+
+ @Test
+ @DisplayName("대표주소가_목록_첫_번째로_반환된다")
+ void 대표주소가_목록_첫_번째로_반환된다() {
+ // given
+ MemberAddr nonMain = MemberAddrFixture.createAddr(member, "삼성동", "서울특별시 강남구 테헤란로 1", false);
+ MemberAddr main = MemberAddrFixture.createAddr(member, "역삼동", "서울특별시 강남구 테헤란로 1", true);
+ ReflectionTestUtils.setField(nonMain, "id", 1L);
+ ReflectionTestUtils.setField(main, "id", 2L);
+
+ member.getAddresses().addAll(List.of(nonMain, main)); // 비대표, 대표 순서로 추가
+
+ given(memberRepository.findById(member.getId())).willReturn(Optional.of(member));
+
+ // when
+ List result = memberQueryService.getAllAddress(member.getId());
+
+ // then
+ assertThat(result).hasSize(2);
+ assertThat(result.get(0).isMainAddr()).isTrue();
+ assertThat(result.get(0).addr3()).isEqualTo("역삼동");
+ }
+
+ @Test
+ @DisplayName("대표주소_제외_나머지는_id_오름차순으로_정렬된다")
+ void 대표주소_제외_나머지는_id_오름차순으로_정렬된다() {
+ // given
+ MemberAddr main = MemberAddrFixture.createAddr(member, "역삼동", "서울특별시 강남구 테헤란로 1", true);
+ MemberAddr non1 = MemberAddrFixture.createAddr(member, "삼성동", "서울특별시 강남구 테헤란로 1", false);
+ MemberAddr non2 = MemberAddrFixture.createAddr(member, "청담동", "서울특별시 강남구 테헤란로 1", false);
+ ReflectionTestUtils.setField(main, "id", 1L);
+ ReflectionTestUtils.setField(non1, "id", 2L);
+ ReflectionTestUtils.setField(non2, "id", 3L);
+
+ member.getAddresses().addAll(List.of(non2, non1, main)); // 섞인 순서로 추가
+
+ given(memberRepository.findById(member.getId())).willReturn(Optional.of(member));
+
+ // when
+ List result = memberQueryService.getAllAddress(member.getId());
+
+ // then
+ assertThat(result).hasSize(3);
+ assertThat(result.get(0).addr3()).isEqualTo("역삼동"); // 대표주소 먼저
+ assertThat(result.get(1).addr3()).isEqualTo("삼성동"); // id=2
+ assertThat(result.get(2).addr3()).isEqualTo("청담동"); // id=3
+ }
+ }
+
+ @Nested
+ @DisplayName("실패")
+ class Failure {
+
+ @Test
+ @DisplayName("존재하지_않는_회원이면_MEMBER_NOT_FOUND_예외를_던진다")
+ void 존재하지_않는_회원이면_MEMBER_NOT_FOUND_예외를_던진다() {
+ // given
+ given(memberRepository.findById(999L)).willReturn(Optional.empty());
+
+ // when & then
+ assertThatThrownBy(() -> memberQueryService.getAllAddress(999L))
+ .isInstanceOf(MemberException.class)
+ .hasFieldOrPropertyWithValue("code", MemberErrorCode.MEMBER_NOT_FOUND);
+ }
+
+ @Test
+ @DisplayName("주소가_없으면_MEMBER_ADDRESS_MINIMUM_REQUIRED_예외를_던진다")
+ void 주소가_없으면_MEMBER_ADDRESS_MINIMUM_REQUIRED_예외를_던진다() {
+ // given: 주소가 비어있는 경우
+ given(memberRepository.findById(member.getId())).willReturn(Optional.of(member));
+
+ // when & then
+ assertThatThrownBy(() -> memberQueryService.getAllAddress(member.getId()))
+ .isInstanceOf(MemberException.class)
+ .hasFieldOrPropertyWithValue("code", MemberErrorCode.MEMBER_ADDRESS_MINIMUM_REQUIRED);
+ }
+ }
+ }
+}
diff --git a/src/test/java/umc/cockple/demo/domain/notification/fcm/FcmIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/notification/fcm/FcmIntegrationTest.java
new file mode 100644
index 000000000..2b73693d3
--- /dev/null
+++ b/src/test/java/umc/cockple/demo/domain/notification/fcm/FcmIntegrationTest.java
@@ -0,0 +1,134 @@
+package umc.cockple.demo.domain.notification.fcm;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.firebase.messaging.FirebaseMessaging;
+import org.junit.jupiter.api.*;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.test.web.servlet.MockMvc;
+import umc.cockple.demo.domain.member.domain.Member;
+import umc.cockple.demo.domain.member.repository.MemberRepository;
+import umc.cockple.demo.domain.notification.dto.FcmTokenRequestDTO;
+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.SecurityContextHelper;
+import umc.cockple.demo.support.fixture.MemberFixture;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+class FcmIntegrationTest extends IntegrationTestBase {
+
+ @Autowired MockMvc mockMvc;
+ @Autowired MemberRepository memberRepository;
+ @Autowired ObjectMapper objectMapper;
+
+ private Member member;
+
+ @BeforeEach
+ void setUp() {
+ member = memberRepository.save(
+ MemberFixture.createMember("테스터", Gender.MALE, Level.A, 1001L));
+ }
+
+ @AfterEach
+ void tearDown() {
+ memberRepository.deleteAll();
+ SecurityContextHelper.clearAuthentication();
+ }
+
+
+ @Nested
+ @DisplayName("PATCH /api/notifications/fcm-token - FCM 토큰 등록/갱신")
+ class RegisterFcmToken {
+
+ @Nested
+ @DisplayName("성공 케이스")
+ class Success {
+
+ @Test
+ @DisplayName("200 - FCM 토큰이 DB에 저장된다")
+ void registerFcmToken_savedInDb() throws Exception {
+ SecurityContextHelper.setAuthentication(member.getId(), member.getNickname());
+
+ mockMvc.perform(patch("/api/notifications/fcm-token")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(new FcmTokenRequestDTO("new-fcm-token"))))
+ .andExpect(status().isOk());
+
+ Member updated = memberRepository.findById(member.getId()).orElseThrow();
+ assertThat(updated.getFcmToken()).isEqualTo("new-fcm-token");
+ }
+
+ @Test
+ @DisplayName("200 - 기존 토큰이 새 토큰으로 교체된다")
+ void registerFcmToken_updatesExistingToken() throws Exception {
+ ReflectionTestUtils.setField(member, "fcmToken", "old-fcm-token");
+ memberRepository.save(member);
+ SecurityContextHelper.setAuthentication(member.getId(), member.getNickname());
+
+ mockMvc.perform(patch("/api/notifications/fcm-token")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(new FcmTokenRequestDTO("updated-fcm-token"))))
+ .andExpect(status().isOk());
+
+ Member updated = memberRepository.findById(member.getId()).orElseThrow();
+ assertThat(updated.getFcmToken()).isEqualTo("updated-fcm-token");
+ }
+ }
+
+ @Nested
+ @DisplayName("실패 케이스")
+ class Failure {
+
+ @Test
+ @DisplayName("400 - 빈 문자열 토큰은 @NotBlank 검증에서 거부된다")
+ void registerFcmToken_blankToken_returns400() throws Exception {
+ SecurityContextHelper.setAuthentication(member.getId(), member.getNickname());
+
+ mockMvc.perform(patch("/api/notifications/fcm-token")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(new FcmTokenRequestDTO(""))))
+ .andExpect(status().isBadRequest());
+ }
+
+ @Test
+ @DisplayName("400 - null 토큰은 @NotBlank 검증에서 거부된다")
+ void registerFcmToken_nullToken_returns400() throws Exception {
+ SecurityContextHelper.setAuthentication(member.getId(), member.getNickname());
+
+ mockMvc.perform(patch("/api/notifications/fcm-token")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(new FcmTokenRequestDTO(null))))
+ .andExpect(status().isBadRequest());
+ }
+
+ @Test
+ @DisplayName("400 - 공백 문자열 토큰은 @NotBlank 검증에서 거부된다")
+ void registerFcmToken_whitespaceToken_returns400() throws Exception {
+ SecurityContextHelper.setAuthentication(member.getId(), member.getNickname());
+
+ mockMvc.perform(patch("/api/notifications/fcm-token")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(new FcmTokenRequestDTO(" "))))
+ .andExpect(status().isBadRequest());
+ }
+
+ @Test
+ @DisplayName("400 - fcmToken 필드 누락 시 @NotBlank 검증에서 거부된다")
+ void registerFcmToken_missingField_returns400() throws Exception {
+ SecurityContextHelper.setAuthentication(member.getId(), member.getNickname());
+
+ mockMvc.perform(patch("/api/notifications/fcm-token")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{}"))
+ .andExpect(status().isBadRequest());
+ }
+ }
+ }
+
+}
diff --git a/src/test/java/umc/cockple/demo/domain/notification/fcm/FcmServiceTest.java b/src/test/java/umc/cockple/demo/domain/notification/fcm/FcmServiceTest.java
new file mode 100644
index 000000000..25a8c96ce
--- /dev/null
+++ b/src/test/java/umc/cockple/demo/domain/notification/fcm/FcmServiceTest.java
@@ -0,0 +1,99 @@
+package umc.cockple.demo.domain.notification.fcm;
+
+import com.google.firebase.messaging.FirebaseMessaging;
+import com.google.firebase.messaging.FirebaseMessagingException;
+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.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 org.assertj.core.api.Assertions.assertThatCode;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.BDDMockito.then;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+
+@ExtendWith(MockitoExtension.class)
+@DisplayName("FcmService")
+class FcmServiceTest {
+
+ @InjectMocks
+ private FcmService fcmService;
+
+ @Mock
+ private FirebaseMessaging firebaseMessaging;
+
+ private Member memberWithToken;
+ private Member memberWithoutToken;
+
+ @BeforeEach
+ void setUp() {
+ memberWithToken = MemberFixture.createMember("토큰있음", Gender.MALE, Level.C, 1001L);
+ ReflectionTestUtils.setField(memberWithToken, "id", 1L);
+ ReflectionTestUtils.setField(memberWithToken, "fcmToken", "valid-fcm-token");
+
+ memberWithoutToken = MemberFixture.createMember("토큰없음", Gender.MALE, Level.C, 1002L);
+ ReflectionTestUtils.setField(memberWithoutToken, "id", 2L);
+ }
+
+ @Nested
+ @DisplayName("sendNotification")
+ class SendNotification {
+
+ @Nested
+ @DisplayName("전송 생략")
+ class Skip {
+
+ @Test
+ @DisplayName("fcmToken이_null이면_전송하지_않는다")
+ void fcmToken이_null이면_전송하지_않는다() throws FirebaseMessagingException {
+ // when
+ fcmService.sendNotification(memberWithoutToken, "제목", "내용");
+
+ // then
+ then(firebaseMessaging).should(never()).send(any());
+ }
+
+ @Test
+ @DisplayName("fcmToken이_빈_문자열이면_전송하지_않는다")
+ void fcmToken이_빈_문자열이면_전송하지_않는다() throws FirebaseMessagingException {
+ // given
+ ReflectionTestUtils.setField(memberWithoutToken, "fcmToken", "");
+
+ // when
+ fcmService.sendNotification(memberWithoutToken, "제목", "내용");
+
+ // then
+ then(firebaseMessaging).should(never()).send(any());
+ }
+ }
+
+ @Nested
+ @DisplayName("전송 실패")
+ class Failure {
+
+ @Test
+ @DisplayName("Firebase_전송_실패해도_예외를_던지지_않는다")
+ void Firebase_전송_실패해도_예외를_던지지_않는다() throws FirebaseMessagingException {
+ // given
+ FirebaseMessagingException exception = mock(FirebaseMessagingException.class);
+ given(firebaseMessaging.send(any())).willThrow(exception);
+
+ // when & then
+ assertThatCode(() -> fcmService.sendNotification(memberWithToken, "제목", "내용"))
+ .doesNotThrowAnyException();
+ }
+ }
+ }
+}
diff --git a/src/test/java/umc/cockple/demo/domain/notification/integration/NotificationIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/notification/integration/NotificationIntegrationTest.java
new file mode 100644
index 000000000..42ebb5554
--- /dev/null
+++ b/src/test/java/umc/cockple/demo/domain/notification/integration/NotificationIntegrationTest.java
@@ -0,0 +1,297 @@
+package umc.cockple.demo.domain.notification.integration;
+
+import org.junit.jupiter.api.*;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.test.web.servlet.MockMvc;
+import umc.cockple.demo.domain.file.service.FileService;
+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.enums.NotificationType;
+import umc.cockple.demo.domain.notification.exception.NotificationErrorCode;
+import umc.cockple.demo.domain.notification.repository.NotificationRepository;
+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.SecurityContextHelper;
+import umc.cockple.demo.support.fixture.MemberFixture;
+
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.nullValue;
+import static org.mockito.BDDMockito.given;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
+
+class NotificationIntegrationTest extends IntegrationTestBase {
+
+ @Autowired MockMvc mockMvc;
+ @Autowired MemberRepository memberRepository;
+ @Autowired NotificationRepository notificationRepository;
+
+ @MockitoBean
+ FileService fileService;
+
+ private Member member;
+ private Notification notification;
+
+ @BeforeEach
+ void setUp() {
+ member = memberRepository.save(MemberFixture.createMember("테스터", Gender.MALE, Level.A, 1001L));
+
+ notification = notificationRepository.save(Notification.builder()
+ .member(member)
+ .partyId(100L)
+ .title("테스트 모임")
+ .content("테스트 알림 내용")
+ .type(NotificationType.INVITE)
+ .isRead(false)
+ .imageKey("test-image-key")
+ .data("{\"invitationId\":1}")
+ .build());
+
+ given(fileService.getUrlFromKey("test-image-key"))
+ .willReturn("https://test-storage.com/test-image-key");
+ given(fileService.getUrlFromKey(null))
+ .willReturn(null);
+ }
+
+ @AfterEach
+ void tearDown() {
+ notificationRepository.deleteAll();
+ memberRepository.deleteAll();
+ SecurityContextHelper.clearAuthentication();
+ }
+
+
+ @Nested
+ @DisplayName("GET /api/notifications - 내 알림 전체 조회")
+ class GetAllNotifications {
+
+ @Nested
+ @DisplayName("성공 케이스")
+ class Success {
+
+ @Test
+ @DisplayName("200 - 알림 목록의 모든 필드를 반환한다")
+ void getAllNotifications_allFields() throws Exception {
+ SecurityContextHelper.setAuthentication(member.getId(), member.getNickname());
+
+ mockMvc.perform(get("/api/notifications"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data", hasSize(1)))
+ .andExpect(jsonPath("$.data[0].notificationId").value(notification.getId()))
+ .andExpect(jsonPath("$.data[0].partyId").value(100))
+ .andExpect(jsonPath("$.data[0].title").value("테스트 모임"))
+ .andExpect(jsonPath("$.data[0].content").value("테스트 알림 내용"))
+ .andExpect(jsonPath("$.data[0].type").value("INVITE"))
+ .andExpect(jsonPath("$.data[0].isRead").value(false))
+ .andExpect(jsonPath("$.data[0].imgUrl").value("https://test-storage.com/test-image-key"))
+ .andExpect(jsonPath("$.data[0].data").value("{\"invitationId\":1}"));
+ }
+
+ @Test
+ @DisplayName("200 - 알림이 없으면 빈 리스트를 반환한다")
+ void getAllNotifications_empty() throws Exception {
+ Member otherMember = memberRepository.save(
+ MemberFixture.createMember("다른멤버", Gender.FEMALE, Level.B, 2002L));
+ SecurityContextHelper.setAuthentication(otherMember.getId(), otherMember.getNickname());
+
+ mockMvc.perform(get("/api/notifications"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data", hasSize(0)));
+ }
+
+ @Test
+ @DisplayName("200 - 알림 목록이 createdAt 기준 내림차순으로 정렬된다")
+ void getAllNotifications_sortedByCreatedAtDesc() throws Exception {
+ // 먼저 저장된 알림 (더 오래된)
+ Notification olderNotification = notificationRepository.save(Notification.builder()
+ .member(member)
+ .partyId(200L)
+ .title("오래된 알림")
+ .content("먼저 생성된 알림")
+ .type(NotificationType.SIMPLE)
+ .isRead(false)
+ .imageKey(null)
+ .data("{}")
+ .build());
+
+ Thread.sleep(10); // createdAt이 서로 다르도록 대기
+
+ // 나중에 저장된 알림 (더 최신)
+ Notification newerNotification = notificationRepository.save(Notification.builder()
+ .member(member)
+ .partyId(300L)
+ .title("최신 알림")
+ .content("나중에 생성된 알림")
+ .type(NotificationType.SIMPLE)
+ .isRead(false)
+ .imageKey(null)
+ .data("{}")
+ .build());
+
+ SecurityContextHelper.setAuthentication(member.getId(), member.getNickname());
+
+ // 내림차순이면 newerNotification → olderNotification → setUp의 notification 순
+ mockMvc.perform(get("/api/notifications"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data", hasSize(3)))
+ .andExpect(jsonPath("$.data[0].notificationId").value(newerNotification.getId()))
+ .andExpect(jsonPath("$.data[1].notificationId").value(olderNotification.getId()))
+ .andExpect(jsonPath("$.data[2].notificationId").value(notification.getId()));
+ }
+
+ @Test
+ @DisplayName("200 - imageKey가 없는 알림은 imgUrl이 null로 반환된다")
+ void getAllNotifications_nullImgUrl() throws Exception {
+ notificationRepository.save(Notification.builder()
+ .member(member)
+ .partyId(200L)
+ .title("이미지 없는 알림")
+ .content("모임이 삭제되었어요!")
+ .type(NotificationType.SIMPLE)
+ .isRead(false)
+ .imageKey(null)
+ .data("{}")
+ .build());
+ SecurityContextHelper.setAuthentication(member.getId(), member.getNickname());
+
+ mockMvc.perform(get("/api/notifications"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data", hasSize(2)))
+ .andExpect(jsonPath("$.data[0].imgUrl").value(nullValue()));
+ }
+ }
+ }
+
+
+ @Nested
+ @DisplayName("GET /api/notifications/count - 안 읽은 알림 존재여부 조회")
+ class CheckUnreadNotification {
+
+ @Nested
+ @DisplayName("성공 케이스")
+ class Success {
+
+ @Test
+ @DisplayName("200 - 읽지 않은 알림이 있으면 existNewNotification이 true이다")
+ void checkUnread_hasUnread() throws Exception {
+ SecurityContextHelper.setAuthentication(member.getId(), member.getNickname());
+
+ mockMvc.perform(get("/api/notifications/count"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.existNewNotification").value(true));
+ }
+
+ @Test
+ @DisplayName("200 - 모든 알림을 읽으면 existNewNotification이 false이다")
+ void checkUnread_allRead() throws Exception {
+ notification.read();
+ notificationRepository.save(notification);
+ SecurityContextHelper.setAuthentication(member.getId(), member.getNickname());
+
+ mockMvc.perform(get("/api/notifications/count"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.existNewNotification").value(false));
+ }
+
+ @Test
+ @DisplayName("200 - 알림이 전혀 없으면 existNewNotification이 false이다")
+ void checkUnread_noNotification() throws Exception {
+ Member otherMember = memberRepository.save(
+ MemberFixture.createMember("다른멤버", Gender.FEMALE, Level.B, 2002L));
+ SecurityContextHelper.setAuthentication(otherMember.getId(), otherMember.getNickname());
+
+ mockMvc.perform(get("/api/notifications/count"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.existNewNotification").value(false));
+ }
+ }
+ }
+
+
+ @Nested
+ @DisplayName("PATCH /api/notifications/{notificationId} - 특정 알림 읽음 처리")
+ class MarkAsReadNotification {
+
+ @Nested
+ @DisplayName("성공 케이스")
+ class Success {
+
+ @Test
+ @DisplayName("200 - INVITE 알림을 INVITE_ACCEPT로 읽음 처리하면 변경된 타입을 반환한다")
+ void markAsRead_inviteAccept() throws Exception {
+ SecurityContextHelper.setAuthentication(member.getId(), member.getNickname());
+
+ mockMvc.perform(patch("/api/notifications/{notificationId}", notification.getId())
+ .param("type", NotificationType.INVITE_ACCEPT.name()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.type").value("INVITE_ACCEPT"));
+ }
+
+ @Test
+ @DisplayName("200 - INVITE 알림을 INVITE_REJECT로 읽음 처리하면 변경된 타입을 반환한다")
+ void markAsRead_inviteReject() throws Exception {
+ SecurityContextHelper.setAuthentication(member.getId(), member.getNickname());
+
+ mockMvc.perform(patch("/api/notifications/{notificationId}", notification.getId())
+ .param("type", NotificationType.INVITE_REJECT.name()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.type").value("INVITE_REJECT"));
+ }
+
+ @Test
+ @DisplayName("200 - SIMPLE 알림을 읽음 처리하면 SIMPLE 타입을 반환한다")
+ void markAsRead_simple() throws Exception {
+ Notification simpleNotification = notificationRepository.save(Notification.builder()
+ .member(member)
+ .partyId(100L)
+ .title("테스트 모임")
+ .content("모임이 삭제되었어요!")
+ .type(NotificationType.SIMPLE)
+ .isRead(false)
+ .imageKey(null)
+ .data("{}")
+ .build());
+ SecurityContextHelper.setAuthentication(member.getId(), member.getNickname());
+
+ mockMvc.perform(patch("/api/notifications/{notificationId}", simpleNotification.getId())
+ .param("type", NotificationType.SIMPLE.name()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.type").value("SIMPLE"));
+ }
+ }
+
+ @Nested
+ @DisplayName("실패 케이스")
+ class Failure {
+
+ @Test
+ @DisplayName("404 - 존재하지 않는 알림 ID이면 에러를 반환한다")
+ void notificationNotFound() throws Exception {
+ SecurityContextHelper.setAuthentication(member.getId(), member.getNickname());
+
+ mockMvc.perform(patch("/api/notifications/{notificationId}", 999L)
+ .param("type", NotificationType.SIMPLE.name()))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.code").value(NotificationErrorCode.NOTIFICATION_NOT_FOUND.getCode()))
+ .andExpect(jsonPath("$.message").value(NotificationErrorCode.NOTIFICATION_NOT_FOUND.getMessage()));
+ }
+
+ @Test
+ @DisplayName("401 - 다른 사용자의 알림에 접근하면 에러를 반환한다")
+ void notificationNotOwned() throws Exception {
+ Member otherMember = memberRepository.save(
+ MemberFixture.createMember("다른멤버", Gender.FEMALE, Level.B, 2002L));
+ SecurityContextHelper.setAuthentication(otherMember.getId(), otherMember.getNickname());
+
+ mockMvc.perform(patch("/api/notifications/{notificationId}", notification.getId())
+ .param("type", NotificationType.SIMPLE.name()))
+ .andExpect(status().isUnauthorized())
+ .andExpect(jsonPath("$.code").value(NotificationErrorCode.NOTIFICATION_NOT_OWNED.getCode()))
+ .andExpect(jsonPath("$.message").value(NotificationErrorCode.NOTIFICATION_NOT_OWNED.getMessage()));
+ }
+ }
+ }
+}
diff --git a/src/test/java/umc/cockple/demo/domain/notification/service/NotificationCommandServiceTest.java b/src/test/java/umc/cockple/demo/domain/notification/service/NotificationCommandServiceTest.java
new file mode 100644
index 000000000..d117e782b
--- /dev/null
+++ b/src/test/java/umc/cockple/demo/domain/notification/service/NotificationCommandServiceTest.java
@@ -0,0 +1,375 @@
+package umc.cockple.demo.domain.notification.service;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+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.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.CreateNotificationRequestDTO;
+import umc.cockple.demo.domain.notification.enums.NotificationTarget;
+import umc.cockple.demo.domain.notification.enums.NotificationType;
+import umc.cockple.demo.domain.notification.exception.NotificationErrorCode;
+import umc.cockple.demo.domain.notification.exception.NotificationException;
+import umc.cockple.demo.domain.notification.fcm.FcmService;
+import umc.cockple.demo.domain.notification.repository.NotificationRepository;
+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;
+import umc.cockple.demo.global.enums.Gender;
+import umc.cockple.demo.global.enums.Level;
+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.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.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.BDDMockito.then;
+import static org.mockito.Mockito.never;
+import static umc.cockple.demo.domain.notification.dto.MarkAsReadDTO.*;
+
+@ExtendWith(MockitoExtension.class)
+@DisplayName("NotificationCommandService")
+class NotificationCommandServiceTest {
+
+ @InjectMocks
+ private NotificationCommandService notificationCommandService;
+
+ @Mock private NotificationRepository notificationRepository;
+ @Mock private MemberRepository memberRepository;
+ @Mock private PartyRepository partyRepository;
+ @Mock private NotificationMessageGenerator notificationMessageGenerator;
+ @Mock private ObjectMapper objectMapper;
+ @Mock private FcmService fcmService;
+
+ private Member member;
+ private Party party;
+ private Notification notification;
+
+ @BeforeEach
+ void setUp() {
+ member = MemberFixture.createMember("테스터", Gender.MALE, Level.A, 1001L);
+ ReflectionTestUtils.setField(member, "id", 1L);
+
+ party = PartyFixture.createParty("테스트 모임", member.getId(),
+ PartyFixture.createPartyAddr("서울특별시", "강남구"));
+ ReflectionTestUtils.setField(party, "id", 10L);
+
+ notification = Notification.builder()
+ .member(member)
+ .partyId(party.getId())
+ .title("테스트 모임")
+ .content("테스트 알림 내용")
+ .type(NotificationType.INVITE)
+ .isRead(false)
+ .imageKey(null)
+ .data("{\"invitationId\":1}")
+ .build();
+ ReflectionTestUtils.setField(notification, "id", 100L);
+ }
+
+
+ @Nested
+ @DisplayName("markAsReadNotification - 알림 읽음 처리")
+ class MarkAsReadNotification {
+
+ @Nested
+ @DisplayName("성공 케이스")
+ class Success {
+
+ @Test
+ @DisplayName("INVITE 알림을 INVITE_ACCEPT로 읽음 처리하면 변경된 타입과 읽음 상태를 반환한다")
+ void markAsRead_inviteAccept_returnsChangedType() {
+ // given
+ given(notificationRepository.findById(notification.getId()))
+ .willReturn(Optional.of(notification));
+
+ // when
+ Response result = notificationCommandService.markAsReadNotification(
+ member.getId(), notification.getId(), NotificationType.INVITE_ACCEPT);
+
+ // then
+ assertThat(result.type()).isEqualTo(NotificationType.INVITE_ACCEPT);
+ assertThat(notification.getIsRead()).isTrue();
+ }
+
+ @Test
+ @DisplayName("INVITE 알림을 INVITE_REJECT로 읽음 처리하면 변경된 타입과 읽음 상태를 반환한다")
+ void markAsRead_inviteReject_returnsChangedType() {
+ // given
+ given(notificationRepository.findById(notification.getId()))
+ .willReturn(Optional.of(notification));
+
+ // when
+ Response result = notificationCommandService.markAsReadNotification(
+ member.getId(), notification.getId(), NotificationType.INVITE_REJECT);
+
+ // then
+ assertThat(result.type()).isEqualTo(NotificationType.INVITE_REJECT);
+ assertThat(notification.getIsRead()).isTrue();
+ }
+
+ @Test
+ @DisplayName("SIMPLE 알림을 읽음 처리하면 SIMPLE 타입과 읽음 상태를 반환한다")
+ void markAsRead_simple_returnsSameType() {
+ // given
+ Notification simpleNotification = Notification.builder()
+ .member(member)
+ .partyId(100L)
+ .title("단순 알림")
+ .content("모임이 삭제되었어요!")
+ .type(NotificationType.SIMPLE)
+ .isRead(false)
+ .imageKey(null)
+ .data("{}")
+ .build();
+ ReflectionTestUtils.setField(simpleNotification, "id", 200L);
+ given(notificationRepository.findById(200L)).willReturn(Optional.of(simpleNotification));
+
+ // when
+ Response result = notificationCommandService.markAsReadNotification(
+ member.getId(), 200L, NotificationType.SIMPLE);
+
+ // then
+ assertThat(result.type()).isEqualTo(NotificationType.SIMPLE);
+ assertThat(simpleNotification.getIsRead()).isTrue();
+ }
+ }
+
+ @Nested
+ @DisplayName("실패 케이스")
+ class Failure {
+
+ @Test
+ @DisplayName("존재하지 않는 알림이면 NotificationException(NOTIFICATION_NOT_FOUND)을 던진다")
+ void notificationNotFound_throwsNotificationException() {
+ // given
+ given(notificationRepository.findById(999L)).willReturn(Optional.empty());
+
+ // when & then
+ assertThatThrownBy(() -> notificationCommandService.markAsReadNotification(
+ member.getId(), 999L, NotificationType.SIMPLE))
+ .isInstanceOf(NotificationException.class)
+ .satisfies(e -> assertThat(((NotificationException) e).getCode())
+ .isEqualTo(NotificationErrorCode.NOTIFICATION_NOT_FOUND));
+ }
+
+ @Test
+ @DisplayName("다른 사용자의 알림이면 NotificationException(NOTIFICATION_NOT_OWNED)을 던진다")
+ void notificationNotOwned_throwsNotificationException() {
+ // given
+ given(notificationRepository.findById(notification.getId()))
+ .willReturn(Optional.of(notification));
+
+ // when & then
+ assertThatThrownBy(() -> notificationCommandService.markAsReadNotification(
+ 999L, notification.getId(), NotificationType.SIMPLE))
+ .isInstanceOf(NotificationException.class)
+ .satisfies(e -> assertThat(((NotificationException) e).getCode())
+ .isEqualTo(NotificationErrorCode.NOTIFICATION_NOT_OWNED));
+ }
+ }
+ }
+
+
+ @Nested
+ @DisplayName("createNotification - 알림 생성")
+ class CreateNotification {
+
+ @Nested
+ @DisplayName("성공 케이스")
+ class Success {
+
+ @Test
+ @DisplayName("PARTY_DELETE 알림을 생성하면 저장 및 FCM 전송이 호출된다")
+ void createNotification_partyDelete_savesAndSendsFcm() throws Exception {
+ // given
+ CreateNotificationRequestDTO dto = CreateNotificationRequestDTO.builder()
+ .member(member)
+ .partyId(party.getId())
+ .target(NotificationTarget.PARTY_DELETE)
+ .build();
+
+ given(notificationRepository.findAllByMemberOrderByCreatedAtDesc(member))
+ .willReturn(List.of());
+ given(partyRepository.findById(party.getId())).willReturn(Optional.of(party));
+ given(notificationMessageGenerator.generatePartyDeletedMessage())
+ .willReturn("모임이 삭제되었어요!");
+ given(objectMapper.writeValueAsString(any())).willReturn("{}");
+
+ // when
+ notificationCommandService.createNotification(dto);
+
+ // then
+ then(notificationRepository).should().save(any(Notification.class));
+ then(fcmService).should().sendNotification(eq(member), eq("테스트 모임"), eq("모임이 삭제되었어요!"));
+ }
+
+ @Test
+ @DisplayName("PARTY_INVITE 알림을 생성하면 title이 '새로운 모임'으로 FCM이 전송된다")
+ void createNotification_partyInvite_usesTitleNewParty() throws Exception {
+ // given
+ CreateNotificationRequestDTO dto = CreateNotificationRequestDTO.builder()
+ .member(member)
+ .partyId(party.getId())
+ .invitationId(1L)
+ .target(NotificationTarget.PARTY_INVITE)
+ .build();
+
+ given(notificationRepository.findAllByMemberOrderByCreatedAtDesc(member))
+ .willReturn(List.of());
+ given(partyRepository.findById(party.getId())).willReturn(Optional.of(party));
+ given(notificationMessageGenerator.generateInviteMessage(party.getPartyName()))
+ .willReturn("'테스트 모임' 모임에 초대를 받았습니다.");
+ given(objectMapper.writeValueAsString(any())).willReturn("{\"invitationId\":1}");
+
+ // when
+ notificationCommandService.createNotification(dto);
+
+ // then
+ then(fcmService).should().sendNotification(eq(member), eq("새로운 모임"), any(String.class));
+ }
+
+ @Test
+ @DisplayName("EXERCISE_DELETE 알림을 생성하면 날짜 포맷을 포함한 메시지로 저장된다")
+ void createNotification_exerciseDelete_savesWithFormattedDate() throws Exception {
+ // given
+ LocalDate exerciseDate = LocalDate.of(2025, 3, 15);
+ CreateNotificationRequestDTO dto = CreateNotificationRequestDTO.builder()
+ .member(member)
+ .partyId(party.getId())
+ .exerciseDate(exerciseDate)
+ .target(NotificationTarget.EXERCISE_DELETE)
+ .build();
+
+ given(notificationRepository.findAllByMemberOrderByCreatedAtDesc(member))
+ .willReturn(List.of());
+ given(partyRepository.findById(party.getId())).willReturn(Optional.of(party));
+ given(notificationMessageGenerator.generateExerciseDeletedMessage("03.15(토)"))
+ .willReturn("03.15(토) 운동이 삭제되었어요!");
+ given(objectMapper.writeValueAsString(any())).willReturn("{}");
+
+ // when
+ notificationCommandService.createNotification(dto);
+
+ // then
+ then(notificationRepository).should().save(any(Notification.class));
+ then(fcmService).should().sendNotification(eq(member), eq("테스트 모임"), eq("03.15(토) 운동이 삭제되었어요!"));
+ }
+
+ @Test
+ @DisplayName("알림이 50개 이상이면 INVITE가 아닌 가장 오래된 알림을 삭제 후 저장한다")
+ void createNotification_over50_deletesOldestNonInvite() throws Exception {
+ // given
+ List existingNotifications = new ArrayList<>();
+ for (int i = 0; i < 50; i++) {
+ existingNotifications.add(Notification.builder()
+ .member(member).partyId(100L).title("t").content("c")
+ .type(NotificationType.SIMPLE).isRead(true).imageKey(null).data("{}").build());
+ }
+
+ Notification oldestNonInvite = Notification.builder()
+ .member(member).partyId(100L).title("오래된 알림").content("c")
+ .type(NotificationType.SIMPLE).isRead(true).imageKey(null).data("{}").build();
+
+ CreateNotificationRequestDTO dto = CreateNotificationRequestDTO.builder()
+ .member(member)
+ .partyId(party.getId())
+ .target(NotificationTarget.PARTY_DELETE)
+ .build();
+
+ given(notificationRepository.findAllByMemberOrderByCreatedAtDesc(member))
+ .willReturn(existingNotifications);
+ given(notificationRepository.findFirstByMemberAndTypeNotOrderByCreatedAtAsc(
+ member, NotificationType.INVITE))
+ .willReturn(Optional.of(oldestNonInvite));
+ given(partyRepository.findById(party.getId())).willReturn(Optional.of(party));
+ given(notificationMessageGenerator.generatePartyDeletedMessage())
+ .willReturn("모임이 삭제되었어요!");
+ given(objectMapper.writeValueAsString(any())).willReturn("{}");
+
+ // when
+ notificationCommandService.createNotification(dto);
+
+ // then
+ then(notificationRepository).should().delete(oldestNonInvite);
+ then(notificationRepository).should().save(any(Notification.class));
+ }
+
+ @Test
+ @DisplayName("알림이 49개이면 오래된 알림 삭제 없이 바로 저장한다")
+ void createNotification_under50_savesWithoutDelete() throws Exception {
+ // given
+ List existingNotifications = new ArrayList<>();
+ for (int i = 0; i < 49; i++) {
+ existingNotifications.add(Notification.builder()
+ .member(member).partyId(100L).title("t").content("c")
+ .type(NotificationType.SIMPLE).isRead(true).imageKey(null).data("{}").build());
+ }
+
+ CreateNotificationRequestDTO dto = CreateNotificationRequestDTO.builder()
+ .member(member)
+ .partyId(party.getId())
+ .target(NotificationTarget.PARTY_DELETE)
+ .build();
+
+ given(notificationRepository.findAllByMemberOrderByCreatedAtDesc(member))
+ .willReturn(existingNotifications);
+ given(partyRepository.findById(party.getId())).willReturn(Optional.of(party));
+ given(notificationMessageGenerator.generatePartyDeletedMessage())
+ .willReturn("모임이 삭제되었어요!");
+ given(objectMapper.writeValueAsString(any())).willReturn("{}");
+
+ // when
+ notificationCommandService.createNotification(dto);
+
+ // then
+ then(notificationRepository).should(never()).delete(any());
+ then(notificationRepository).should().save(any(Notification.class));
+ }
+ }
+
+ @Nested
+ @DisplayName("실패 케이스")
+ class Failure {
+
+ @Test
+ @DisplayName("존재하지 않는 모임이면 PartyException(PARTY_NOT_FOUND)을 던지고 저장하지 않는다")
+ void partyNotFound_throwsPartyException() {
+ // given
+ CreateNotificationRequestDTO dto = CreateNotificationRequestDTO.builder()
+ .member(member)
+ .partyId(999L)
+ .target(NotificationTarget.PARTY_DELETE)
+ .build();
+
+ given(notificationRepository.findAllByMemberOrderByCreatedAtDesc(member))
+ .willReturn(List.of());
+ given(partyRepository.findById(999L)).willReturn(Optional.empty());
+
+ // when & then
+ assertThatThrownBy(() -> notificationCommandService.createNotification(dto))
+ .isInstanceOf(PartyException.class)
+ .satisfies(e -> assertThat(((PartyException) e).getCode())
+ .isEqualTo(PartyErrorCode.PARTY_NOT_FOUND));
+
+ then(notificationRepository).should(never()).save(any());
+ }
+ }
+ }
+}
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
new file mode 100644
index 000000000..8aef6bdc5
--- /dev/null
+++ b/src/test/java/umc/cockple/demo/domain/notification/service/NotificationQueryServiceTest.java
@@ -0,0 +1,273 @@
+package umc.cockple.demo.domain.notification.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.file.service.FileService;
+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.domain.notification.domain.Notification;
+import umc.cockple.demo.domain.notification.dto.AllNotificationsResponseDTO;
+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.global.enums.Gender;
+import umc.cockple.demo.global.enums.Level;
+import umc.cockple.demo.support.fixture.MemberFixture;
+
+import java.time.LocalDateTime;
+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.BDDMockito.given;
+
+@ExtendWith(MockitoExtension.class)
+@DisplayName("NotificationQueryService")
+class NotificationQueryServiceTest {
+
+ @InjectMocks
+ private NotificationQueryService notificationQueryService;
+
+ @Mock private NotificationRepository notificationRepository;
+ @Mock private MemberRepository memberRepository;
+ @Mock private FileService fileService;
+
+ private Member member;
+ private Notification notification;
+
+ @BeforeEach
+ void setUp() {
+ member = MemberFixture.createMember("테스터", Gender.MALE, Level.A, 1001L);
+ ReflectionTestUtils.setField(member, "id", 1L);
+
+ notification = Notification.builder()
+ .member(member)
+ .partyId(100L)
+ .title("테스트 모임")
+ .content("테스트 알림 내용")
+ .type(NotificationType.INVITE)
+ .isRead(false)
+ .imageKey("test-image-key")
+ .data("{\"invitationId\":1}")
+ .build();
+ ReflectionTestUtils.setField(notification, "id", 10L);
+ }
+
+
+ @Nested
+ @DisplayName("getAllNotifications - 내 알림 전체 조회")
+ class GetAllNotifications {
+
+ @Nested
+ @DisplayName("성공 케이스")
+ class Success {
+
+ @Test
+ @DisplayName("알림 목록을 DTO로 변환하여 반환한다")
+ void getAllNotifications_returnsDtoList() {
+ // given
+ given(memberRepository.findById(member.getId())).willReturn(Optional.of(member));
+ given(notificationRepository.findAllByMemberOrderByCreatedAtDesc(member))
+ .willReturn(List.of(notification));
+ given(fileService.getUrlFromKey("test-image-key"))
+ .willReturn("https://test-storage.com/test-image-key");
+
+ // when
+ List result = notificationQueryService.getAllNotifications(member.getId());
+
+ // then
+ assertThat(result).hasSize(1);
+ assertThat(result.get(0).notificationId()).isEqualTo(10L);
+ assertThat(result.get(0).partyId()).isEqualTo(100L);
+ assertThat(result.get(0).title()).isEqualTo("테스트 모임");
+ assertThat(result.get(0).content()).isEqualTo("테스트 알림 내용");
+ assertThat(result.get(0).type()).isEqualTo(NotificationType.INVITE);
+ assertThat(result.get(0).isRead()).isFalse();
+ assertThat(result.get(0).imgUrl()).isEqualTo("https://test-storage.com/test-image-key");
+ assertThat(result.get(0).data()).isEqualTo("{\"invitationId\":1}");
+ }
+
+ @Test
+ @DisplayName("imageKey가 없는 알림은 imgUrl이 null로 반환된다")
+ void getAllNotifications_nullImageKey_returnsNullImgUrl() {
+ // given
+ Notification noImageNotification = Notification.builder()
+ .member(member)
+ .partyId(200L)
+ .title("이미지 없는 알림")
+ .content("모임이 삭제되었어요!")
+ .type(NotificationType.SIMPLE)
+ .isRead(false)
+ .imageKey(null)
+ .data("{}")
+ .build();
+ ReflectionTestUtils.setField(noImageNotification, "id", 20L);
+
+ given(memberRepository.findById(member.getId())).willReturn(Optional.of(member));
+ given(notificationRepository.findAllByMemberOrderByCreatedAtDesc(member))
+ .willReturn(List.of(noImageNotification));
+ given(fileService.getUrlFromKey(null)).willReturn(null);
+
+ // when
+ List result = notificationQueryService.getAllNotifications(member.getId());
+
+ // then
+ assertThat(result).hasSize(1);
+ assertThat(result.get(0).imgUrl()).isNull();
+ }
+
+ @Test
+ @DisplayName("레포지토리가 반환한 createdAt 내림차순 순서를 그대로 유지하여 반환한다")
+ void getAllNotifications_preservesDescOrderFromRepository() {
+ // given
+ Notification oldest = Notification.builder()
+ .member(member).partyId(100L).title("오래된 알림").content("c1")
+ .type(NotificationType.SIMPLE).isRead(true).imageKey(null).data("{}").build();
+ ReflectionTestUtils.setField(oldest, "id", 1L);
+ ReflectionTestUtils.setField(oldest, "createdAt", LocalDateTime.of(2025, 1, 1, 9, 0));
+
+ Notification middle = Notification.builder()
+ .member(member).partyId(100L).title("중간 알림").content("c2")
+ .type(NotificationType.CHANGE).isRead(false).imageKey(null).data("{}").build();
+ ReflectionTestUtils.setField(middle, "id", 2L);
+ ReflectionTestUtils.setField(middle, "createdAt", LocalDateTime.of(2025, 6, 1, 9, 0));
+
+ Notification newest = Notification.builder()
+ .member(member).partyId(100L).title("최신 알림").content("c3")
+ .type(NotificationType.INVITE).isRead(false).imageKey(null).data("{}").build();
+ ReflectionTestUtils.setField(newest, "id", 3L);
+ ReflectionTestUtils.setField(newest, "createdAt", LocalDateTime.of(2025, 12, 1, 9, 0));
+
+ // 레포지토리는 createdAt DESC 순으로 반환 (newest → middle → oldest)
+ given(memberRepository.findById(member.getId())).willReturn(Optional.of(member));
+ given(notificationRepository.findAllByMemberOrderByCreatedAtDesc(member))
+ .willReturn(List.of(newest, middle, oldest));
+ given(fileService.getUrlFromKey(null)).willReturn(null);
+
+ // when
+ List result = notificationQueryService.getAllNotifications(member.getId());
+
+ // then
+ assertThat(result).hasSize(3);
+ assertThat(result.get(0).notificationId()).isEqualTo(3L); // newest
+ assertThat(result.get(1).notificationId()).isEqualTo(2L); // middle
+ assertThat(result.get(2).notificationId()).isEqualTo(1L); // oldest
+ }
+
+ @Test
+ @DisplayName("알림이 없으면 빈 리스트를 반환한다")
+ void getAllNotifications_noNotifications_returnsEmptyList() {
+ // given
+ given(memberRepository.findById(member.getId())).willReturn(Optional.of(member));
+ given(notificationRepository.findAllByMemberOrderByCreatedAtDesc(member))
+ .willReturn(List.of());
+
+ // when
+ List result = notificationQueryService.getAllNotifications(member.getId());
+
+ // then
+ assertThat(result).isEmpty();
+ }
+ }
+
+ @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.getAllNotifications(999L))
+ .isInstanceOf(MemberException.class)
+ .satisfies(e -> assertThat(((MemberException) e).getCode())
+ .isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND));
+ }
+ }
+ }
+
+
+ @Nested
+ @DisplayName("checkUnreadNotification - 읽지 않은 알림 존재여부 조회")
+ class CheckUnreadNotification {
+
+ @Nested
+ @DisplayName("성공 케이스")
+ class Success {
+
+ @Test
+ @DisplayName("읽지 않은 알림이 있으면 existNewNotification이 true이다")
+ void hasUnreadNotification_returnsTrue() {
+ // given
+ ReflectionTestUtils.setField(member, "notifications", List.of(notification));
+ given(memberRepository.findById(member.getId())).willReturn(Optional.of(member));
+
+ // when
+ ExistNewNotificationResponseDTO result = notificationQueryService.checkUnreadNotification(member.getId());
+
+ // then
+ assertThat(result.existNewNotification()).isTrue();
+ }
+
+ @Test
+ @DisplayName("모든 알림이 읽힌 상태이면 existNewNotification이 false이다")
+ void allNotificationsRead_returnsFalse() {
+ // given
+ notification.read();
+ ReflectionTestUtils.setField(member, "notifications", List.of(notification));
+ given(memberRepository.findById(member.getId())).willReturn(Optional.of(member));
+
+ // when
+ ExistNewNotificationResponseDTO result = notificationQueryService.checkUnreadNotification(member.getId());
+
+ // 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/domain/party/integration/PartyIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/party/integration/PartyIntegrationTest.java
index 87f8f8245..25fe51839 100644
--- a/src/test/java/umc/cockple/demo/domain/party/integration/PartyIntegrationTest.java
+++ b/src/test/java/umc/cockple/demo/domain/party/integration/PartyIntegrationTest.java
@@ -1,18 +1,39 @@
package umc.cockple.demo.domain.party.integration;
-import org.junit.jupiter.api.*;
+import com.fasterxml.jackson.databind.ObjectMapper;
+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.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.MediaType;
+import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.servlet.MockMvc;
+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.repository.ChatRoomMemberRepository;
+import umc.cockple.demo.domain.chat.repository.ChatRoomRepository;
import umc.cockple.demo.domain.exercise.domain.Exercise;
import umc.cockple.demo.domain.exercise.repository.ExerciseRepository;
import umc.cockple.demo.domain.member.domain.Member;
+import umc.cockple.demo.domain.member.domain.MemberAddr;
+import umc.cockple.demo.domain.member.domain.MemberParty;
+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;
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.domain.PartyInvitation;
+import umc.cockple.demo.domain.party.domain.PartyJoinRequest;
+import umc.cockple.demo.domain.party.dto.*;
+import umc.cockple.demo.domain.party.enums.*;
import umc.cockple.demo.domain.party.exception.PartyErrorCode;
import umc.cockple.demo.domain.party.repository.PartyAddrRepository;
+import umc.cockple.demo.domain.party.repository.PartyInvitationRepository;
+import umc.cockple.demo.domain.party.repository.PartyJoinRequestRepository;
import umc.cockple.demo.domain.party.repository.PartyRepository;
import umc.cockple.demo.global.enums.Gender;
import umc.cockple.demo.global.enums.Level;
@@ -24,20 +45,42 @@
import umc.cockple.demo.support.fixture.PartyFixture;
import java.time.LocalDate;
+import java.util.List;
-import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+@Transactional
class PartyIntegrationTest extends IntegrationTestBase {
- @Autowired MockMvc mockMvc;
- @Autowired MemberRepository memberRepository;
- @Autowired PartyRepository partyRepository;
- @Autowired MemberPartyRepository memberPartyRepository;
- @Autowired PartyAddrRepository partyAddrRepository;
- @Autowired ExerciseRepository exerciseRepository;
- @Autowired MemberExerciseRepository memberExerciseRepository;
+ @Autowired
+ MockMvc mockMvc;
+ @Autowired
+ MemberRepository memberRepository;
+ @Autowired
+ PartyRepository partyRepository;
+ @Autowired
+ MemberPartyRepository memberPartyRepository;
+ @Autowired
+ PartyAddrRepository partyAddrRepository;
+ @Autowired
+ ExerciseRepository exerciseRepository;
+ @Autowired
+ MemberExerciseRepository memberExerciseRepository;
+ @Autowired
+ MemberAddrRepository memberAddrRepository;
+ @Autowired
+ ChatRoomRepository chatRoomRepository;
+ @Autowired
+ ChatRoomMemberRepository chatRoomMemberRepository;
+ @Autowired
+ PartyJoinRequestRepository partyJoinRequestRepository;
+ @Autowired
+ PartyInvitationRepository partyInvitationRepository;
+ @Autowired
+ ObjectMapper objectMapper;
private Member manager;
private Member normalMember;
@@ -45,86 +88,1564 @@ class PartyIntegrationTest extends IntegrationTestBase {
@BeforeEach
void setUp() {
- manager = memberRepository.save(MemberFixture.createMember("매니저", Gender.MALE, Level.A, 1001L));
+ // 매니저 및 주소 정보 생성
+ manager = memberRepository.save(MemberFixture.createMember("매니저", Gender.MALE, Level.A, 1001L, LocalDate.of(1995, 1, 1)));
+ memberAddrRepository.save(MemberAddr.builder()
+ .member(manager)
+ .addr1("서울특별시")
+ .addr2("강남구")
+ .addr3("역삼동")
+ .streetAddr("테헤란로")
+ .latitude(37.5)
+ .longitude(127.0)
+ .isMain(true)
+ .build());
+
+ // 일반 멤버 생성
normalMember = memberRepository.save(MemberFixture.createMember("일반멤버", Gender.FEMALE, Level.B, 1002L));
+ // 모임 및 주소 정보 생성
PartyAddr addr = partyAddrRepository.save(PartyFixture.createPartyAddr("서울특별시", "강남구"));
party = partyRepository.save(PartyFixture.createParty("테스트 모임", manager.getId(), addr));
- memberPartyRepository.save(MemberFixture.createMemberParty(party, manager, Role.party_MANAGER));
- memberPartyRepository.save(MemberFixture.createMemberParty(party, normalMember, Role.party_MEMBER));
+ // 모임 멤버 생성
+ memberPartyRepository.save(MemberFixture.createMemberParty(party, manager, Role.PARTY_MANAGER));
+ memberPartyRepository.save(MemberFixture.createMemberParty(party, normalMember, Role.PARTY_MEMBER));
+
+ // 채팅방 생성
+ ChatRoom chatRoom = chatRoomRepository.save(ChatRoom.createPartyChatRoom(party));
+ chatRoomMemberRepository.save(ChatRoomMember.create(chatRoom, manager));
+ chatRoomMemberRepository.save(ChatRoomMember.create(chatRoom, normalMember));
+
+ // 추천 조회용 모임 (manager의 조건에 맞춤)
+ Party suggestedParty = PartyFixture.createParty("추천 모임", normalMember.getId(), addr);
+ suggestedParty.addLevel(Gender.MALE, Level.A);
+ partyRepository.save(suggestedParty);
SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
}
- @AfterEach
- void tearDown() {
- memberExerciseRepository.deleteAll();
- exerciseRepository.deleteAll();
- memberPartyRepository.deleteAll();
- partyRepository.deleteAll();
- partyAddrRepository.deleteAll();
- memberRepository.deleteAll();
- }
@Nested
@DisplayName("GET /api/parties/{partyId}/members - 모임 멤버 조회")
class GetPartyMembers {
@Test
- @DisplayName("200 - 멤버 목록과 마지막 운동일을 정상 반환한다")
- void success_withLastExerciseDate() throws Exception {
- Exercise exercise = exerciseRepository.save(
- ExerciseFixture.createExercise(party, LocalDate.of(2025, 1, 10)));
+ @DisplayName("200 - 멤버 목록을 역할, 성별 통계 및 마지막 운동일과 함께 조회한다")
+ void success_getPartyMembers() throws Exception {
+ // 부모임장 추가
+ Member subManager = memberRepository.save(MemberFixture.createMember("부매니저", Gender.MALE, Level.A, 1003L));
+ memberPartyRepository.save(MemberFixture.createMemberParty(party, subManager, Role.PARTY_SUBMANAGER));
+
+ // 운동 기록 추가
+ Exercise exercise = exerciseRepository.save(ExerciseFixture.createExercise(party, LocalDate.of(2025, 1, 10)));
memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, exercise));
mockMvc.perform(get("/api/parties/{partyId}/members", party.getId()))
.andExpect(status().isOk())
- .andExpect(jsonPath("$.data.summary.totalCount").value(2))
- .andExpect(jsonPath("$.data.summary.maleCount").value(1))
+ .andExpect(jsonPath("$.data.summary.totalCount").value(3))
+ .andExpect(jsonPath("$.data.summary.maleCount").value(2))
.andExpect(jsonPath("$.data.summary.femaleCount").value(1))
- // 첫 번째 멤버(매니저) 전체 필드 검증
- .andExpect(jsonPath("$.data.members[0].memberId").value(manager.getId()))
- .andExpect(jsonPath("$.data.members[0].nickname").value("매니저"))
- .andExpect(jsonPath("$.data.members[0].profileImageUrl").doesNotExist())
- .andExpect(jsonPath("$.data.members[0].role").value("party_MANAGER"))
- .andExpect(jsonPath("$.data.members[0].gender").value("MALE"))
- .andExpect(jsonPath("$.data.members[0].level").value("A조"))
+ .andExpect(jsonPath("$.data.members[0].role").value("PARTY_MANAGER"))
.andExpect(jsonPath("$.data.members[0].isMe").value(true))
- .andExpect(jsonPath("$.data.members[0].lastExerciseDate").doesNotExist())
- // 두 번째 멤버(일반멤버) 마지막 운동일 검증
- .andExpect(jsonPath("$.data.members[1].lastExerciseDate").value("2025-01-10"));
+ .andExpect(jsonPath("$.data.members[1].role").value("PARTY_SUBMANAGER"))
+ .andExpect(jsonPath("$.data.members[2].role").value("PARTY_MEMBER"))
+ .andExpect(jsonPath("$.data.members[2].lastExerciseDate").value("2025-01-10"));
}
@Test
- @DisplayName("200 - 운동 기록이 없는 멤버의 lastExerciseDate는 null이다")
- void success_noExerciseHistory() throws Exception {
+ @DisplayName("404 - 존재하지 않는 파티면 에러를 반환한다")
+ void fail_getPartyMembers_partyNotFound() throws Exception {
+ mockMvc.perform(get("/api/parties/{partyId}/members", 999L))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.code").value(PartyErrorCode.PARTY_NOT_FOUND.getCode()));
+ }
+
+ @Test
+ @DisplayName("400 - 비활성화된 파티면 에러를 반환한다")
+ void fail_getPartyMembers_partyInactive() throws Exception {
+ party.delete();
+ partyRepository.save(party);
+
mockMvc.perform(get("/api/parties/{partyId}/members", party.getId()))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(PartyErrorCode.PARTY_IS_DELETED.getCode()));
+ }
+ }
+
+ @Nested
+ @DisplayName("DELETE /api/parties/{partyId}/members/my - 모임 탈퇴")
+ class LeaveParty {
+
+ @Test
+ @DisplayName("200 - 일반 멤버가 모임을 성공적으로 탈퇴한다")
+ void success_leaveParty() throws Exception {
+ // DB에서 최신 정보 보장
+ Member member = memberRepository.findById(normalMember.getId()).orElseThrow();
+ Party targetParty = partyRepository.findById(party.getId()).orElseThrow();
+
+ // normalMember 세션으로 설정
+ SecurityContextHelper.setAuthentication(member.getId(), member.getNickname());
+
+ mockMvc.perform(delete("/api/parties/{partyId}/members/my", targetParty.getId()))
.andExpect(status().isOk())
- .andExpect(jsonPath("$.data.summary.totalCount").value(2))
- .andExpect(jsonPath("$.data.members[0].lastExerciseDate").isEmpty())
- .andExpect(jsonPath("$.data.members[1].lastExerciseDate").isEmpty());
+ .andExpect(jsonPath("$.code").value("COMMON200"));
+
+ // DB에서 제거되었는지 확인
+ boolean exists = memberPartyRepository.existsByPartyAndMember(targetParty, member);
+ assertThat(exists).isFalse();
}
@Test
- @DisplayName("404 - 존재하지 않는 파티면 에러를 반환한다")
- void fail_partyNotFound() throws Exception {
- mockMvc.perform(get("/api/parties/{partyId}/members", 999L))
+ @DisplayName("403 - 모임장은 탈퇴할 수 없다")
+ void fail_leaveParty_owner() throws Exception {
+ // manager(모임장) 세션으로 설정
+ SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+
+ mockMvc.perform(delete("/api/parties/{partyId}/members/my", party.getId()))
+ .andExpect(status().isForbidden())
+ .andExpect(jsonPath("$.code").value(PartyErrorCode.INVALID_ACTION_FOR_OWNER.getCode()));
+ }
+
+ @Test
+ @DisplayName("403 - 부모임장은 탈퇴할 수 없다")
+ void fail_leaveParty_subOwner() throws Exception {
+ // 부모임장 생성 및 가입
+ Member subManager = memberRepository.save(MemberFixture.createMember("부매니저", Gender.MALE, Level.A, 3001L));
+ memberPartyRepository.save(MemberFixture.createMemberParty(party, subManager, Role.PARTY_SUBMANAGER));
+
+ // 부모임장 세션으로 설정
+ SecurityContextHelper.setAuthentication(subManager.getId(), subManager.getNickname());
+
+ mockMvc.perform(delete("/api/parties/{partyId}/members/my", party.getId()))
+ .andExpect(status().isForbidden())
+ .andExpect(jsonPath("$.code").value(PartyErrorCode.INVALID_ACTION_FOR_SUBOWNER.getCode()));
+ }
+
+ @Test
+ @DisplayName("400 - 해당 모임의 멤버가 아니면 탈퇴할 수 없다")
+ void fail_leaveParty_notMember() throws Exception {
+ // 가입하지 않은 새로운 멤버 생성
+ Member nonMember = memberRepository.save(MemberFixture.createMember("외부인", Gender.MALE, Level.A, 4002L));
+ SecurityContextHelper.setAuthentication(nonMember.getId(), nonMember.getNickname());
+
+ mockMvc.perform(delete("/api/parties/{partyId}/members/my", party.getId()))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(PartyErrorCode.NOT_MEMBER.getCode()));
+ }
+
+ @Test
+ @DisplayName("404 - 존재하지 않는 파티에서 탈퇴 시도 시 PARTY_NOT_FOUND 에러를 반환한다")
+ void fail_leaveParty_partyNotFound() throws Exception {
+ mockMvc.perform(delete("/api/parties/{partyId}/members/my", 9999L))
.andExpect(status().isNotFound())
- .andExpect(jsonPath("$.code").value(PartyErrorCode.PARTY_NOT_FOUND.getCode()))
- .andExpect(jsonPath("$.message").value(PartyErrorCode.PARTY_NOT_FOUND.getMessage()));
+ .andExpect(jsonPath("$.code").value(PartyErrorCode.PARTY_NOT_FOUND.getCode()));
+ }
+ }
+
+ @Nested
+ @DisplayName("GET /api/my/parties - 내 모임 조회")
+ class GetMyParties {
+
+ Party party2;
+ Party party3;
+
+ @BeforeEach
+ void setUpMyParties() {
+ PartyAddr addr = partyAddrRepository.findAll().get(0);
+
+ // party: 1번째 생성, 운동 횟수 10
+ ReflectionTestUtils.setField(party, "exerciseCount", 10);
+ partyRepository.save(party);
+
+ // party2: 2번째 생성, 운동 횟수 20
+ party2 = partyRepository.save(PartyFixture.createParty("테스트 모임 2", manager.getId(), addr));
+ memberPartyRepository.save(MemberFixture.createMemberParty(party2, manager, Role.PARTY_MANAGER));
+ ReflectionTestUtils.setField(party2, "exerciseCount", 20);
+ partyRepository.save(party2);
+
+ // party3: 3번째 생성, 운동 횟수 5
+ party3 = partyRepository.save(PartyFixture.createParty("테스트 모임 3", manager.getId(), addr));
+ memberPartyRepository.save(MemberFixture.createMemberParty(party3, manager, Role.PARTY_MANAGER));
+ ReflectionTestUtils.setField(party3, "exerciseCount", 5);
+ partyRepository.save(party3);
}
@Test
- @DisplayName("400 - 비활성화된 파티면 에러를 반환한다")
- void fail_partyInactive() throws Exception {
+ @DisplayName("200 - 사용자가 가입한 모임 목록을 최신순(기본)으로 페이징하여 반환한다")
+ void success_getMyParties() throws Exception {
+ mockMvc.perform(get("/api/my/parties")
+ .param("created", "false")
+ .param("sort", "최신순")
+ .param("size", "10")
+ .param("page", "0"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value("COMMON200"))
+ .andExpect(jsonPath("$.data.content").isArray())
+ .andExpect(jsonPath("$.data.content.length()").value(3))
+ .andExpect(jsonPath("$.data.content[0].partyId").value(party3.getId()))
+ .andExpect(jsonPath("$.data.content[1].partyId").value(party2.getId()))
+ .andExpect(jsonPath("$.data.content[2].partyId").value(party.getId()));
+ }
+
+ @Test
+ @DisplayName("200 - 사용자가 가입한 모임 목록을 오래된 순으로 페이징하여 반환한다")
+ void success_getMyParties_oldest() throws Exception {
+ mockMvc.perform(get("/api/my/parties")
+ .param("created", "false")
+ .param("sort", "오래된 순")
+ .param("size", "10")
+ .param("page", "0"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value("COMMON200"))
+ .andExpect(jsonPath("$.data.content").isArray())
+ .andExpect(jsonPath("$.data.content.length()").value(3))
+ .andExpect(jsonPath("$.data.content[0].partyId").value(party.getId()))
+ .andExpect(jsonPath("$.data.content[1].partyId").value(party2.getId()))
+ .andExpect(jsonPath("$.data.content[2].partyId").value(party3.getId()));
+ }
+
+ @Test
+ @DisplayName("200 - 사용자가 가입한 모임 목록을 운동 많은 순으로 페이징하여 반환한다")
+ void success_getMyParties_exerciseCount() throws Exception {
+ mockMvc.perform(get("/api/my/parties")
+ .param("created", "false")
+ .param("sort", "운동 많은 순")
+ .param("size", "10")
+ .param("page", "0"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value("COMMON200"))
+ .andExpect(jsonPath("$.data.content").isArray())
+ .andExpect(jsonPath("$.data.content.length()").value(3))
+ .andExpect(jsonPath("$.data.content[0].partyId").value(party2.getId())) // 20회
+ .andExpect(jsonPath("$.data.content[1].partyId").value(party.getId())) // 10회
+ .andExpect(jsonPath("$.data.content[2].partyId").value(party3.getId())); // 5회
+ }
+
+ @Test
+ @DisplayName("200 - 가입한 모임이 없을 경우 빈 목록을 반환한다")
+ void success_emptyMyParties() throws Exception {
+ Member newMember = memberRepository.save(MemberFixture.createMember("뉴비",
+ Gender.MALE, Level.BEGINNER, 3003L));
+ SecurityContextHelper.setAuthentication(newMember.getId(), newMember.getNickname());
+
+ mockMvc.perform(get("/api/my/parties")
+ .param("created", "false")
+ .param("sort", "최신순")
+ .param("size", "10")
+ .param("page", "0"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value("COMMON200"))
+ .andExpect(jsonPath("$.data.content").isArray())
+ .andExpect(jsonPath("$.data.content").isEmpty())
+ .andExpect(jsonPath("$.data.empty").value(true));
+ }
+ }
+
+ @Nested
+ @DisplayName("GET /api/my/parties/simple - 내 모임 간략화 조회")
+ class GetSimpleMyParties {
+
+ Party party2;
+ Party party3;
+
+ @BeforeEach
+ void setUpSimpleMyParties() {
+ PartyAddr addr = partyAddrRepository.findAll().get(0);
+
+ // party2
+ party2 = partyRepository.save(PartyFixture.createParty("간략 모임 2", manager.getId(), addr));
+ memberPartyRepository.save(MemberFixture.createMemberParty(party2, manager, Role.PARTY_MANAGER));
+
+ // party3
+ party3 = partyRepository.save(PartyFixture.createParty("간략 모임 3", manager.getId(), addr));
+ memberPartyRepository.save(MemberFixture.createMemberParty(party3, manager, Role.PARTY_MANAGER));
+ }
+
+ @Test
+ @DisplayName("200 - 사용자가 가입한 모임의 간략화된 목록을 페이징하여 반환한다")
+ void success_getSimpleMyParties() throws Exception {
+ mockMvc.perform(get("/api/my/parties/simple")
+ .param("page", "0")
+ .param("size", "10")
+ .param("sort", "createdAt,DESC"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value("COMMON200"))
+ .andExpect(jsonPath("$.data.content").isArray())
+ .andExpect(jsonPath("$.data.content.length()").value(3))
+ .andExpect(jsonPath("$.data.content[0].partyId").value(party3.getId()))
+ .andExpect(jsonPath("$.data.content[1].partyId").value(party2.getId()))
+ .andExpect(jsonPath("$.data.content[2].partyId").value(party.getId()));
+ }
+
+ @Test
+ @DisplayName("200 - 가입한 모임이 없을 경우 빈 목록을 반환한다")
+ void success_emptySimpleMyParties() throws Exception {
+ Member newMember = memberRepository.save(MemberFixture.createMember("뉴비",
+ Gender.MALE, Level.BEGINNER, 3003L));
+ SecurityContextHelper.setAuthentication(newMember.getId(), newMember.getNickname());
+
+ mockMvc.perform(get("/api/my/parties/simple")
+ .param("page", "0")
+ .param("size", "10")
+ .param("sort", "createdAt,DESC"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value("COMMON200"))
+ .andExpect(jsonPath("$.message").value("요청에 성공했습니다."))
+ .andExpect(jsonPath("$.data.content").isArray())
+ .andExpect(jsonPath("$.data.content").isEmpty())
+ .andExpect(jsonPath("$.data.empty").value(true));
+ }
+ }
+
+ @Nested
+ @DisplayName("GET /api/my/parties/suggestions - 모임 추천 조회")
+ class GetRecommendedParties {
+
+ Party recParty1;
+ Party recParty2;
+ Party recParty3;
+
+ @BeforeEach
+ void setUpRecommends() {
+ PartyAddr addr = partyAddrRepository.findAll().get(0);
+
+ // recParty1: 1번째 생성, 운동 횟수 10
+ recParty1 = partyRepository.findAll().stream()
+ .filter(p -> p.getPartyName().equals("추천 모임"))
+ .findFirst().orElseThrow();
+ ReflectionTestUtils.setField(recParty1, "exerciseCount", 10);
+ partyRepository.save(recParty1);
+
+ // recParty2: 1번째 생성, 운동 횟수 20
+ recParty2 = partyRepository.save(PartyFixture.createParty("추천 모임 2", normalMember.getId(), addr));
+ recParty2.addLevel(Gender.MALE, Level.A); // manager 조건에 맞도록
+ ReflectionTestUtils.setField(recParty2, "exerciseCount", 20);
+ partyRepository.save(recParty2);
+
+ // recParty3: 3번째 생성, 운동 횟수 5
+ recParty3 = partyRepository.save(PartyFixture.createParty("추천 모임 3", normalMember.getId(), addr));
+ recParty3.addLevel(Gender.MALE, Level.A); // manager 조건에 맞도록
+ ReflectionTestUtils.setField(recParty3, "exerciseCount", 5);
+ partyRepository.save(recParty3);
+ }
+
+ @Test
+ @DisplayName("200 - Cockple 추천 모드 시 추천된 모임 목록 3개를 반환한다")
+ void success_getRecommendedParties_cockpleRecommend() throws Exception {
+ mockMvc.perform(get("/api/my/parties/suggestions")
+ .param("isCockpleRecommend", "true")
+ .param("page", "0")
+ .param("size", "10"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value("COMMON200"))
+ .andExpect(jsonPath("$.data.content").isArray())
+ .andExpect(jsonPath("$.data.content.length()").value(3));
+ }
+
+ @Test
+ @DisplayName("200 - 필터 모드 시 조건에 맞는 모임 목록을 운동 많은 순으로 반환한다")
+ void success_getRecommendedParties_exerciseCount() throws Exception {
+ mockMvc.perform(get("/api/my/parties/suggestions")
+ .param("isCockpleRecommend", "false")
+ .param("sort", "운동 많은 순")
+ .param("page", "0")
+ .param("size", "10"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value("COMMON200"))
+ .andExpect(jsonPath("$.data.content").isArray())
+ .andExpect(jsonPath("$.data.content.length()").value(3))
+ .andExpect(jsonPath("$.data.content[0].partyId").value(recParty2.getId())) // 20회
+ .andExpect(jsonPath("$.data.content[1].partyId").value(recParty1.getId())) // 10회
+ .andExpect(jsonPath("$.data.content[2].partyId").value(recParty3.getId())); // 5회
+ }
+
+ @Test
+ @DisplayName("200 - 필터 모드 시 조건에 맞는 모임 목록을 최신순으로 반환한다")
+ void success_getRecommendedParties_latest() throws Exception {
+ mockMvc.perform(get("/api/my/parties/suggestions")
+ .param("isCockpleRecommend", "false")
+ .param("addr1", "서울특별시")
+ .param("addr2", "강남구")
+ .param("sort", "최신순")
+ .param("page", "0")
+ .param("size", "10"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value("COMMON200"))
+ .andExpect(jsonPath("$.data.content").isArray())
+ .andExpect(jsonPath("$.data.content.length()").value(3))
+ .andExpect(jsonPath("$.data.content[0].partyId").value(recParty3.getId()))
+ .andExpect(jsonPath("$.data.content[1].partyId").value(recParty2.getId()))
+ .andExpect(jsonPath("$.data.content[2].partyId").value(recParty1.getId()));
+ }
+
+ @Test
+ @DisplayName("200 - 필터 모드 시 조건에 맞는 모임 목록을 오래된 순으로 반환한다")
+ void success_getRecommendedParties_oldest() throws Exception {
+ mockMvc.perform(get("/api/my/parties/suggestions")
+ .param("isCockpleRecommend", "false")
+ .param("sort", "오래된 순")
+ .param("page", "0")
+ .param("size", "10"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value("COMMON200"))
+ .andExpect(jsonPath("$.data.content").isArray())
+ .andExpect(jsonPath("$.data.content.length()").value(3))
+ .andExpect(jsonPath("$.data.content[0].partyId").value(recParty1.getId()))
+ .andExpect(jsonPath("$.data.content[1].partyId").value(recParty2.getId()))
+ .andExpect(jsonPath("$.data.content[2].partyId").value(recParty3.getId()));
+ }
+
+ @Test
+ @DisplayName("200 - 검색 모드 시 모임명으로 검색된 결과를 반환한다")
+ void success_getRecommendedParties_search() throws Exception {
+ mockMvc.perform(get("/api/my/parties/suggestions")
+ .param("search", "추천 모임 2")
+ .param("isCockpleRecommend", "false")
+ .param("page", "0")
+ .param("size", "10"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value("COMMON200"))
+ .andExpect(jsonPath("$.data.content").isArray())
+ .andExpect(jsonPath("$.data.content.length()").value(1))
+ .andExpect(jsonPath("$.data.content[0].partyName").value("추천 모임 2"));
+ }
+
+ @Test
+ @DisplayName("400 - 유효하지 않은 정렬 기준 입력 시 INVALID_ORDER_TYPE 에러를 반환한다")
+ void fail_getRecommendedParties_invalidOrderType() throws Exception {
+ mockMvc.perform(get("/api/my/parties/suggestions")
+ .param("isCockpleRecommend", "false")
+ .param("sort", "잘못된순"))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(PartyErrorCode.INVALID_ORDER_TYPE.getCode()));
+ }
+
+ @Test
+ @DisplayName("400 - isCockpleRecommend에 부적절한 타입 입력 시 400 에러를 반환한다")
+ void fail_getRecommendedParties_invalidBooleanType() throws Exception {
+ mockMvc.perform(get("/api/my/parties/suggestions")
+ .param("isCockpleRecommend", "not-boolean"))
+ .andExpect(status().isBadRequest());
+ }
+ }
+
+ @Nested
+ @DisplayName("GET /api/parties/{partyId} - 모임 상세 조회")
+ class GetPartyDetails {
+
+ @Test
+ @DisplayName("200 - 모임 상세 정보를 정상적으로 조회한다 (비회원 상태)")
+ void success_getPartyDetails_nonMember() throws Exception {
+ // 모임에 가입하지 않은 새로운 유저 생성 및 인증 설정
+ Member nonMember = memberRepository.save(MemberFixture.createMember("비회원", Gender.MALE, Level.C, 2001L));
+ SecurityContextHelper.setAuthentication(nonMember.getId(), nonMember.getNickname());
+
+ mockMvc.perform(get("/api/parties/{partyId}", party.getId()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value("COMMON200"))
+ .andExpect(jsonPath("$.data.partyId").value(party.getId()))
+ .andExpect(jsonPath("$.data.memberStatus").value("NOT_MEMBER"))
+ .andExpect(jsonPath("$.data.hasPendingJoinRequest").value(false));
+ }
+
+ @Test
+ @DisplayName("200 - 모임원인 경우 memberStatus가 MEMBER로 반환된다")
+ void success_getPartyDetails_member() throws Exception {
+ // manager는 setUp에서 이미 party의 멤버로 설정됨
+ SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+
+ mockMvc.perform(get("/api/parties/{partyId}", party.getId()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.memberStatus").value("MEMBER"))
+ .andExpect(jsonPath("$.data.memberRole").value("PARTY_MANAGER"));
+ }
+
+ @Test
+ @DisplayName("404 - 존재하지 않는 모임 조회 시 PARTY_NOT_FOUND 에러를 반환한다")
+ void fail_getPartyDetails_partyNotFound() throws Exception {
+ mockMvc.perform(get("/api/parties/{partyId}", 9999L))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.code").value(PartyErrorCode.PARTY_NOT_FOUND.getCode()));
+ }
+
+ @Test
+ @DisplayName("400 - 삭제된 모임 조회 시 PARTY_IS_DELETED 에러를 반환한다")
+ void fail_getPartyDetails_partyDeleted() throws Exception {
+ // 모임 삭제 (비활성화)
party.delete();
partyRepository.save(party);
- mockMvc.perform(get("/api/parties/{partyId}/members", party.getId()))
+ mockMvc.perform(get("/api/parties/{partyId}", party.getId()))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(PartyErrorCode.PARTY_IS_DELETED.getCode()));
+ }
+ }
+
+ @Nested
+ @DisplayName("POST /api/parties/{partyId}/join-requests - 모임 가입 신청")
+ class CreateJoinRequest {
+
+ @Test
+ @DisplayName("200 - 가입하지 않은 회원이 모임 가입을 신청한다")
+ void success_createJoinRequest() throws Exception {
+ // 가입하지 않은 멤버 생성
+ Member applicant = memberRepository.save(MemberFixture.createMember("신청자", Gender.MALE, Level.A, 5001L, LocalDate.of(1995, 1, 1)));
+ SecurityContextHelper.setAuthentication(applicant.getId(), applicant.getNickname());
+
+ mockMvc.perform(post("/api/parties/{partyId}/join-requests", party.getId()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value("COMMON201"));
+
+ // 가입 신청 데이터 확인
+ boolean exists = partyJoinRequestRepository.existsByPartyAndMemberAndStatus(party, applicant, RequestStatus.PENDING);
+ assertThat(exists).isTrue();
+ }
+
+ @Test
+ @DisplayName("409 - 이미 가입된 회원이 다시 가입 신청을 한다")
+ void fail_createJoinRequest_alreadyMember() throws Exception {
+ // 이미 가입된 normalMember 사용
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+
+ mockMvc.perform(post("/api/parties/{partyId}/join-requests", party.getId()))
+ .andExpect(status().isConflict())
+ .andExpect(jsonPath("$.code").value(PartyErrorCode.ALREADY_MEMBER.getCode()));
+ }
+
+ @Test
+ @DisplayName("400 - 성별 조건이 맞지 않는 모임에 신청한다")
+ void fail_createJoinRequest_genderMismatch() throws Exception {
+ // 여복 모임 생성
+ PartyAddr addr = partyAddrRepository.save(PartyFixture.createPartyAddr("서울", "강남"));
+ Party womenParty = partyRepository.save(Party.builder()
+ .partyName("여복 전용 모임")
+ .partyType(ParticipationType.WOMEN_DOUBLES)
+ .status(PartyStatus.ACTIVE)
+ .ownerId(manager.getId())
+ .partyAddr(addr)
+ .minBirthYear(1900)
+ .maxBirthYear(2099)
+ .activityTime(ActivityTime.MORNING)
+ .designatedCock("테스트콕")
+ .exerciseCount(0)
+ .price(0)
+ .joinPrice(0)
+ .build());
+
+ SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+
+ mockMvc.perform(post("/api/parties/{partyId}/join-requests", womenParty.getId()))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(PartyErrorCode.GENDER_NOT_MATCH.getCode()));
+ }
+
+ @Test
+ @DisplayName("409 - 이미 대기중인 가입 신청이 있는 상태에서 다시 신청하면 JOIN_REQUEST_ALREADY_EXISTS 에러를 반환한다")
+ void fail_createJoinRequest_alreadyExists() throws Exception {
+ // 가입하지 않은 멤버 생성
+ Member applicant = memberRepository.save(MemberFixture.createMember("신청자2", Gender.MALE, Level.A, 5002L, LocalDate.of(1995, 1, 1)));
+
+ // 기존 가입 신청 추가
+ PartyJoinRequest joinRequest = PartyJoinRequest.create(applicant, party);
+ partyJoinRequestRepository.save(joinRequest);
+
+ SecurityContextHelper.setAuthentication(applicant.getId(), applicant.getNickname());
+
+ mockMvc.perform(post("/api/parties/{partyId}/join-requests", party.getId()))
+ .andExpect(status().isConflict())
+ .andExpect(jsonPath("$.code").value(PartyErrorCode.JOIN_REQUEST_ALREADY_EXISTS.getCode()));
+ }
+
+ @Test
+ @DisplayName("400 - 삭제된(비활성화된) 모임에 가입 신청하면 PARTY_IS_DELETED 에러를 반환한다")
+ void fail_createJoinRequest_partyDeleted() throws Exception {
+ // 파티 생성 후 삭제
+ PartyAddr addr = partyAddrRepository.save(PartyFixture.createPartyAddr("서울", "금천"));
+ Party deletedParty = partyRepository.save(PartyFixture.createParty("삭제된 모임", manager.getId(), addr));
+ deletedParty.delete();
+ partyRepository.save(deletedParty);
+
+ Member applicant = memberRepository.save(MemberFixture.createMember("신청자3", Gender.MALE, Level.A, 5003L));
+ SecurityContextHelper.setAuthentication(applicant.getId(), applicant.getNickname());
+
+ mockMvc.perform(post("/api/parties/{partyId}/join-requests", deletedParty.getId()))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(PartyErrorCode.PARTY_IS_DELETED.getCode()));
+ }
+
+ @Test
+ @DisplayName("404 - 존재하지 않는 파티에 가입 신청하면 PARTY_NOT_FOUND 에러를 반환한다")
+ void fail_createJoinRequest_partyNotFound() throws Exception {
+ Member applicant = memberRepository.save(MemberFixture.createMember("신청자4", Gender.MALE, Level.A, 5004L));
+ SecurityContextHelper.setAuthentication(applicant.getId(), applicant.getNickname());
+
+ mockMvc.perform(post("/api/parties/{partyId}/join-requests", 9999L))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.code").value(PartyErrorCode.PARTY_NOT_FOUND.getCode()));
+ }
+ }
+
+ @Nested
+ @DisplayName("PATCH /api/parties/{partyId} - 모임 정보 수정")
+ class UpdateParty {
+
+ @Test
+ @DisplayName("200 - 모임장이 유효한 데이터로 모임 정보를 정상적으로 수정한다")
+ void success_updateParty() throws Exception {
+ // given
+ PartyUpdateDTO.Request request = PartyUpdateDTO.Request.builder()
+ .activityDay(List.of("월", "수"))
+ .activityTime("오전")
+ .designatedCock("수정된 콕")
+ .joinPrice(2000)
+ .price(15000)
+ .content("수정된 내용입니다.")
+ .build();
+
+ SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+
+ // when & then
+ mockMvc.perform(patch("/api/parties/{partyId}", party.getId())
+ .contentType("application/json")
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value("COMMON200"));
+
+ // 검증
+ Party updatedParty = partyRepository.findById(party.getId()).orElseThrow();
+ assertThat(updatedParty.getDesignatedCock()).isEqualTo("수정된 콕");
+ assertThat(updatedParty.getJoinPrice()).isEqualTo(2000);
+ assertThat(updatedParty.getPrice()).isEqualTo(15000);
+ assertThat(updatedParty.getContent()).isEqualTo("수정된 내용입니다.");
+ }
+
+ @Test
+ @DisplayName("400 - 필수 필드(activityDay, activityTime) 누락 시 에러를 반환한다")
+ void fail_updateParty_missingRequiredFields() throws Exception {
+ // given
+ PartyUpdateDTO.Request request = PartyUpdateDTO.Request.builder()
+ .activityDay(null)
+ .activityTime("")
+ .build();
+
+ SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+
+ // when & then
+ mockMvc.perform(patch("/api/parties/{partyId}", party.getId())
+ .contentType("application/json")
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value("COMMON400_VALIDATION"));
+ }
+
+ @Test
+ @DisplayName("403 - 모임장이 아닌 일반 멤버가 수정을 시도하면 INSUFFICIENT_PERMISSION 에러를 반환한다")
+ void fail_updateParty_notOwner() throws Exception {
+ // given
+ PartyUpdateDTO.Request request = PartyUpdateDTO.Request.builder()
+ .activityDay(List.of("토", "일"))
+ .activityTime("오후")
+ .build();
+
+ // 일반 멤버로 세션 설정
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+
+ // when & then
+ mockMvc.perform(patch("/api/parties/{partyId}", party.getId())
+ .contentType("application/json")
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isForbidden())
+ .andExpect(jsonPath("$.code").value(PartyErrorCode.INSUFFICIENT_PERMISSION.getCode()));
+ }
+
+ @Test
+ @DisplayName("404 - 존재하지 않는 파티 수정 시 PARTY_NOT_FOUND 에러를 반환한다")
+ void fail_updateParty_partyNotFound() throws Exception {
+ // given
+ PartyUpdateDTO.Request request = PartyUpdateDTO.Request.builder()
+ .activityDay(List.of("월"))
+ .activityTime("오전")
+ .build();
+
+ // when & then
+ mockMvc.perform(patch("/api/parties/{partyId}", 9999L)
+ .contentType("application/json")
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.code").value(PartyErrorCode.PARTY_NOT_FOUND.getCode()));
+ }
+ }
+
+ @Nested
+ @DisplayName("PATCH /api/parties/{partyId}/status - 모임 삭제")
+ class DeleteParty {
+
+ @Test
+ @DisplayName("200 - 모임장이 모임을 성공적으로 삭제(비활성화)한다")
+ void success_deleteParty() throws Exception {
+ // given
+ SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+
+ // when & then
+ mockMvc.perform(patch("/api/parties/{partyId}/status", party.getId()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value("COMMON200"));
+
+ // 검증
+ Party deletedParty = partyRepository.findById(party.getId()).orElseThrow();
+ assertThat(deletedParty.getStatus()).isEqualTo(PartyStatus.INACTIVE);
+ }
+
+ @Test
+ @DisplayName("403 - 모임장이 아닌 멤버가 삭제를 시도하면 INSUFFICIENT_PERMISSION 예외를 반환한다")
+ void fail_deleteParty_notOwner() throws Exception {
+ // given
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+
+ // when & then
+ mockMvc.perform(patch("/api/parties/{partyId}/status", party.getId()))
+ .andExpect(status().isForbidden())
+ .andExpect(jsonPath("$.code").value(PartyErrorCode.INSUFFICIENT_PERMISSION.getCode()));
+ }
+
+ @Test
+ @DisplayName("400 - 이미 삭제된 모임을 다시 삭제 시도하면 PARTY_IS_DELETED 예외를 반환한다")
+ void fail_deleteParty_partyDeleted() throws Exception {
+ // given
+ party.delete(); // 상태 INACTIVE 변경
+ partyRepository.save(party);
+
+ SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+
+ // when & then
+ mockMvc.perform(patch("/api/parties/{partyId}/status", party.getId()))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(PartyErrorCode.PARTY_IS_DELETED.getCode()));
+ }
+
+ @Test
+ @DisplayName("404 - 존재하지 않는 파티 삭제 시 PARTY_NOT_FOUND 에러를 반환한다")
+ void fail_deleteParty_partyNotFound() throws Exception {
+ mockMvc.perform(patch("/api/parties/{partyId}/status", 9999L))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.code").value(PartyErrorCode.PARTY_NOT_FOUND.getCode()));
+ }
+ }
+
+ @Nested
+ @DisplayName("DELETE /api/parties/{partyId}/members/{memberId} - 모임 멤버 삭제")
+ class RemoveMember {
+
+ @Test
+ @DisplayName("200 - 모임장이 일반 멤버를 성공적으로 강퇴한다")
+ void success_removeMember() throws Exception {
+ // given
+ SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+
+ // when & then
+ mockMvc.perform(delete("/api/parties/{partyId}/members/{memberId}", party.getId(), normalMember.getId()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value("COMMON200"));
+
+ // 검증
+ boolean exists = memberPartyRepository.existsByPartyAndMember(party, normalMember);
+ assertThat(exists).isFalse();
+ }
+
+ @Test
+ @DisplayName("403 - 모임장이 아닌 멤버가 삭제를 시도하면 INSUFFICIENT_PERMISSION 에러를 반환한다")
+ void fail_removeMember_notOwner() throws Exception {
+ // given
+ Member someoneElse = memberRepository.save(MemberFixture.createMember("다른멤버", Gender.MALE, Level.B, 1010L));
+ memberPartyRepository.save(MemberFixture.createMemberParty(party, someoneElse, Role.PARTY_MEMBER));
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+
+ // when & then
+ mockMvc.perform(delete("/api/parties/{partyId}/members/{memberId}", party.getId(), someoneElse.getId()))
+ .andExpect(status().isForbidden())
+ .andExpect(jsonPath("$.code").value(PartyErrorCode.INSUFFICIENT_PERMISSION.getCode()));
+ }
+
+ @Test
+ @DisplayName("400 - 모임장이 자기 자신을 강퇴하려 할 경우 CANNOT_REMOVE_SELF 에러를 반환한다")
+ void fail_removeMember_selfAsManager() throws Exception {
+ // given
+ SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+
+ // when & then
+ mockMvc.perform(delete("/api/parties/{partyId}/members/{memberId}", party.getId(), manager.getId()))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(PartyErrorCode.CANNOT_REMOVE_SELF.getCode()));
+ }
+
+ @Test
+ @DisplayName("404 - 존재하지 않는 파티의 멤버 삭제 시 PARTY_NOT_FOUND 에러를 반환한다")
+ void fail_removeMember_partyNotFound() throws Exception {
+ mockMvc.perform(delete("/api/parties/{partyId}/members/{memberId}", 9999L, normalMember.getId()))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.code").value(PartyErrorCode.PARTY_NOT_FOUND.getCode()));
+ }
+
+ @Test
+ @DisplayName("404 - 존재하지 않는 멤버를 강퇴하려 할 때 MEMBER_NOT_FOUND 에러를 반환한다")
+ void fail_removeMember_memberNotFound() throws Exception {
+ mockMvc.perform(delete("/api/parties/{partyId}/members/{memberId}", party.getId(), 9999L))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.code").value(MemberErrorCode.MEMBER_NOT_FOUND.getCode()));
+ }
+ }
+
+ @Nested
+ @DisplayName("GET /api/parties/{partyId}/join-requests - 모임 가입 신청 조회")
+ class GetJoinRequests {
+
+ @Test
+ @DisplayName("200 - 모임장이 가입 신청 목록을 정상적으로 조회한다")
+ void success_getJoinRequests() throws Exception {
+ // given
+ Member applicant = memberRepository.save(MemberFixture.createMember("가입희망자", Gender.FEMALE, Level.B, 1010L));
+
+ PartyJoinRequest joinRequest = PartyJoinRequest.builder()
+ .party(party)
+ .member(applicant)
+ .status(RequestStatus.PENDING)
+ .build();
+ partyJoinRequestRepository.save(joinRequest);
+
+ SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+
+ // when & then
+ mockMvc.perform(get("/api/parties/{partyId}/join-requests", party.getId())
+ .param("status", "PENDING")
+ .param("page", "0")
+ .param("size", "10"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value("COMMON200"))
+ .andExpect(jsonPath("$.data.content[0].userId").value(applicant.getId()));
+ }
+
+ @Test
+ @DisplayName("200 - 모임장이 가입 승인된 멤버 목록(APPROVED)을 정상적으로 조회한다")
+ void success_getJoinRequests_approved() throws Exception {
+ // given
+ Member applicant = memberRepository.save(MemberFixture.createMember("승인된멤버", Gender.MALE, Level.C, 1015L));
+
+ PartyJoinRequest joinRequest = PartyJoinRequest.builder()
+ .party(party)
+ .member(applicant)
+ .status(RequestStatus.APPROVED)
+ .build();
+ partyJoinRequestRepository.save(joinRequest);
+
+ SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+
+ // when & then
+ mockMvc.perform(get("/api/parties/{partyId}/join-requests", party.getId())
+ .param("status", "APPROVED")
+ .param("page", "0")
+ .param("size", "10"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value("COMMON200"))
+ .andExpect(jsonPath("$.data.content[0].userId").value(applicant.getId()));
+ }
+
+ @Test
+ @DisplayName("403 - 모임장이 아닌 사용자가 조회하면 INSUFFICIENT_PERMISSION 예외가 반환된다")
+ void fail_getJoinRequests_notOwner() throws Exception {
+ // given
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+
+ // when & then
+ mockMvc.perform(get("/api/parties/{partyId}/join-requests", party.getId())
+ .param("status", "PENDING"))
+ .andExpect(status().isForbidden())
+ .andExpect(jsonPath("$.code").value(PartyErrorCode.INSUFFICIENT_PERMISSION.getCode()));
+ }
+
+ @Test
+ @DisplayName("400 - 잘못된 상태값을 전달하면 INVALID_REQUEST_STATUS 예외가 반환된다")
+ void fail_getJoinRequests_invalidStatus() throws Exception {
+ // given
+ SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+
+ // when & then
+ mockMvc.perform(get("/api/parties/{partyId}/join-requests", party.getId())
+ .param("status", "UNKNOWN_STATUS"))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(PartyErrorCode.INVALID_REQUEST_STATUS.getCode()));
+ }
+
+ @Test
+ @DisplayName("404 - 존재하지 않는 파티의 가입 신청 목록 조회 시 PARTY_NOT_FOUND 에러를 반환한다")
+ void fail_getJoinRequests_partyNotFound() throws Exception {
+ mockMvc.perform(get("/api/parties/{partyId}/join-requests", 9999L)
+ .param("status", "PENDING"))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.code").value(PartyErrorCode.PARTY_NOT_FOUND.getCode()));
+ }
+ }
+
+ @Nested
+ @DisplayName("GET /api/parties/{partyId}/members/suggestions - 신규 멤버 추천받기")
+ class GetRecommendedMembers {
+
+ @Test
+ @DisplayName("200 - 추천 조건(지역/나이/급수)에 맞는 멤버가 추천 목록에 포함된다")
+ void success_getRecommendedMembers() throws Exception {
+ // given
+ // party의 추천 조건: addr1=서울특별시, minBirthYear=1990, maxBirthYear=2005
+ // party에 남성 A급 레벨 추가
+ party.addLevel(Gender.MALE, Level.A);
+ partyRepository.save(party);
+
+ // 추천 조건을 모두 만족하는 멤버: 남성, A급, 생년 1995, 서울특별시 주소(isMain=true)
+ Member suggestedMember = memberRepository.save(
+ MemberFixture.createMember("추천회원", Gender.MALE, Level.A, 1080L, LocalDate.of(1995, 6, 1))
+ );
+ memberAddrRepository.save(MemberAddr.builder()
+ .member(suggestedMember)
+ .addr1("서울특별시")
+ .addr2("강남구")
+ .addr3("역삼동")
+ .streetAddr("테헤란로")
+ .latitude(37.5)
+ .longitude(127.0)
+ .isMain(true)
+ .build());
+
+ SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+
+ // when & then
+ mockMvc.perform(get("/api/parties/{partyId}/members/suggestions", party.getId())
+ .param("page", "0")
+ .param("size", "10"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value("COMMON200"))
+ .andExpect(jsonPath("$.data.content").isArray())
+ .andExpect(jsonPath("$.data.content[0].userId").value(suggestedMember.getId()));
+ }
+
+ @Test
+ @DisplayName("404 - 존재하지 않는 모임의 추천 멤버를 조회하면 PARTY_NOT_FOUND 예외 발생")
+ void fail_getRecommendedMembers_partyNotFound() throws Exception {
+ // given
+ Long invalidPartyId = 9999L;
+ SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+
+ // when & then
+ mockMvc.perform(get("/api/parties/{partyId}/members/suggestions", invalidPartyId))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.code").value(PartyErrorCode.PARTY_NOT_FOUND.getCode()));
+ }
+ }
+
+ @Nested
+ @DisplayName("POST /api/parties/{partyId}/invitations - 신규 멤버 초대 보내기")
+ class CreateInvitation {
+
+ @Test
+ @DisplayName("200 - 모임장이 새로운 멤버를 초대하고 invitationId를 반환한다")
+ void success_createInvitation() throws Exception {
+ // given
+ Member newMember = memberRepository.save(MemberFixture.createMember("새멤버", Gender.FEMALE, Level.B, 1090L));
+ PartyInviteCreateDTO.Request request = new PartyInviteCreateDTO.Request(newMember.getId());
+ SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+
+ // when & then
+ mockMvc.perform(post("/api/parties/{partyId}/invitations", party.getId())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value("COMMON201"))
+ .andExpect(jsonPath("$.data.invitationId").exists());
+ }
+
+ @Test
+ @DisplayName("403 - 모임장이 아닌 사용자가 초대하면 INSUFFICIENT_PERMISSION 발생")
+ void fail_createInvitation_notOwner() throws Exception {
+ // given
+ Member newMember = memberRepository.save(MemberFixture.createMember("새멤버", Gender.FEMALE, Level.B, 1091L));
+ PartyInviteCreateDTO.Request request = new PartyInviteCreateDTO.Request(newMember.getId());
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+
+ // when & then
+ mockMvc.perform(post("/api/parties/{partyId}/invitations", party.getId())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isForbidden())
+ .andExpect(jsonPath("$.code").value(PartyErrorCode.INSUFFICIENT_PERMISSION.getCode()));
+ }
+
+ @Test
+ @DisplayName("409 - 이미 모임 멤버인 사람을 초대하면 ALREADY_MEMBER 발생")
+ void fail_createInvitation_alreadyMember() throws Exception {
+ // given - normalMember는 setUp()에서 이미 모임 멤버로 추가된 상태
+ PartyInviteCreateDTO.Request request = new PartyInviteCreateDTO.Request(normalMember.getId());
+ SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+
+ // when & then
+ mockMvc.perform(post("/api/parties/{partyId}/invitations", party.getId())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isConflict())
+ .andExpect(jsonPath("$.code").value(PartyErrorCode.ALREADY_MEMBER.getCode()));
+ }
+
+ @Test
+ @DisplayName("409 - 이미 대기 중인 초대가 있는 멤버를 중복 초대하면 INVITATION_ALREADY_EXISTS 발생")
+ void fail_createInvitation_duplicateInvitation() throws Exception {
+ // given
+ Member newMember = memberRepository.save(MemberFixture.createMember("새멤버", Gender.FEMALE, Level.B, 1092L));
+ partyInvitationRepository.save(PartyInvitation.create(party, manager, newMember));
+
+ PartyInviteCreateDTO.Request request = new PartyInviteCreateDTO.Request(newMember.getId());
+ SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+
+ // when & then
+ mockMvc.perform(post("/api/parties/{partyId}/invitations", party.getId())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isConflict())
+ .andExpect(jsonPath("$.code").value(PartyErrorCode.INVITATION_ALREADY_EXISTS.getCode()));
+ }
+
+ @Test
+ @DisplayName("404 - 존재하지 않는 파티에서 멤버 초대 시 PARTY_NOT_FOUND 에러를 반환한다")
+ void fail_createInvitation_partyNotFound() throws Exception {
+ PartyInviteCreateDTO.Request request = new PartyInviteCreateDTO.Request(normalMember.getId());
+ SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+
+ mockMvc.perform(post("/api/parties/{partyId}/invitations", 9999L)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.code").value(PartyErrorCode.PARTY_NOT_FOUND.getCode()));
+ }
+
+ @Test
+ @DisplayName("404 - 존재하지 않는 회원을 모임에 초대하면 MEMBER_NOT_FOUND 에러를 반환한다")
+ void fail_createInvitation_memberNotFound() throws Exception {
+ PartyInviteCreateDTO.Request request = new PartyInviteCreateDTO.Request(9999L);
+ SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+
+ mockMvc.perform(post("/api/parties/{partyId}/invitations", party.getId())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.code").value(MemberErrorCode.MEMBER_NOT_FOUND.getCode()));
+ }
+ }
+
+ @Nested
+ @DisplayName("PATCH /api/parties/invitations/{invitationId} - 모임 초대 처리")
+ class ActionInvitation {
+
+ @Test
+ @DisplayName("200 - 초대받은 멤버가 승인하면 모임 멤버로 추가된다")
+ void success_actionInvitation_approve() throws Exception {
+ // given
+ Member invitee = memberRepository.save(MemberFixture.createMember("초대대상", Gender.FEMALE, Level.B, 1100L));
+
+ PartyInvitation invitation = partyInvitationRepository.save(
+ PartyInvitation.create(party, manager, invitee)
+ );
+
+ PartyInviteActionDTO.Request request = new PartyInviteActionDTO.Request(RequestAction.APPROVE);
+ SecurityContextHelper.setAuthentication(invitee.getId(), invitee.getNickname());
+
+ // when & then
+ mockMvc.perform(patch("/api/parties/invitations/{invitationId}", invitation.getId())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value("COMMON200"));
+
+ // 검증
+ PartyInvitation updated = partyInvitationRepository.findById(invitation.getId()).orElseThrow();
+ assertThat(updated.getStatus()).isEqualTo(RequestStatus.APPROVED);
+ assertThat(memberPartyRepository.existsByPartyAndMember(party, invitee)).isTrue();
+ }
+
+ @Test
+ @DisplayName("200 - 초대받은 멤버가 거절하면 상태가 REJECTED로 바뀌고 멤버로 추가되지 않는다")
+ void success_actionInvitation_reject() throws Exception {
+ // given
+ Member invitee = memberRepository.save(MemberFixture.createMember("초대대상", Gender.FEMALE, Level.B, 1101L));
+
+ PartyInvitation invitation = partyInvitationRepository.save(
+ PartyInvitation.create(party, manager, invitee)
+ );
+
+ PartyInviteActionDTO.Request request = new PartyInviteActionDTO.Request(RequestAction.REJECT);
+ SecurityContextHelper.setAuthentication(invitee.getId(), invitee.getNickname());
+
+ // when & then
+ mockMvc.perform(patch("/api/parties/invitations/{invitationId}", invitation.getId())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value("COMMON200"));
+
+ // 검증
+ PartyInvitation updated = partyInvitationRepository.findById(invitation.getId()).orElseThrow();
+ assertThat(updated.getStatus()).isEqualTo(RequestStatus.REJECTED);
+ assertThat(memberPartyRepository.existsByPartyAndMember(party, invitee)).isFalse();
+ }
+
+ @Test
+ @DisplayName("403 - 초대받은 사람이 아닌 제3자가 처리하면 NOT_YOUR_INVITATION 발생")
+ void fail_actionInvitation_notYourInvitation() throws Exception {
+ // given
+ Member invitee = memberRepository.save(MemberFixture.createMember("초대대상", Gender.FEMALE, Level.B, 1102L));
+
+ PartyInvitation invitation = partyInvitationRepository.save(
+ PartyInvitation.create(party, manager, invitee)
+ );
+
+ PartyInviteActionDTO.Request request = new PartyInviteActionDTO.Request(RequestAction.APPROVE);
+ // normalMember는 초대받은 사람이 아님
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+
+ // when & then
+ mockMvc.perform(patch("/api/parties/invitations/{invitationId}", invitation.getId())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isForbidden())
+ .andExpect(jsonPath("$.code").value(PartyErrorCode.NOT_YOUR_INVITATION.getCode()));
+ }
+
+ @Test
+ @DisplayName("409 - 이미 처리된 초대를 다시 처리하면 INVITATION_ALREADY_ACTIONS 발생")
+ void fail_actionInvitation_alreadyActions() throws Exception {
+ // given
+ Member invitee = memberRepository.save(MemberFixture.createMember("초대대상", Gender.FEMALE, Level.B, 1103L));
+
+ // 이미 APPROVED 처리된 초대
+ PartyInvitation invitation = partyInvitationRepository.save(
+ PartyInvitation.create(party, manager, invitee)
+ );
+ invitation.updateStatus(RequestStatus.APPROVED);
+ partyInvitationRepository.save(invitation);
+
+ PartyInviteActionDTO.Request request = new PartyInviteActionDTO.Request(RequestAction.REJECT);
+ SecurityContextHelper.setAuthentication(invitee.getId(), invitee.getNickname());
+
+ // when & then
+ mockMvc.perform(patch("/api/parties/invitations/{invitationId}", invitation.getId())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isConflict())
+ .andExpect(jsonPath("$.code").value(PartyErrorCode.INVITATION_ALREADY_ACTIONS.getCode()));
+ }
+
+ @Test
+ @DisplayName("404 - 존재하지 않는 회원이 초대를 처리하려 할 때 MEMBER_NOT_FOUND 에러를 반환한다")
+ void fail_actionInvitation_memberNotFound() throws Exception {
+ // given
+ Member invitee = memberRepository.save(MemberFixture.createMember("초대대상", Gender.FEMALE, Level.B, 1104L));
+ PartyInvitation invitation = partyInvitationRepository.save(
+ PartyInvitation.create(party, manager, invitee)
+ );
+
+ PartyInviteActionDTO.Request request = new PartyInviteActionDTO.Request(RequestAction.APPROVE);
+ // 인증 정보에는 유효하지만 DB에는 없는 ID 설정
+ SecurityContextHelper.setAuthentication(9999L, "ghost");
+
+ // when & then
+ mockMvc.perform(patch("/api/parties/invitations/{invitationId}", invitation.getId())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.code").value(MemberErrorCode.MEMBER_NOT_FOUND.getCode()));
+ }
+ }
+
+ @Nested
+ @DisplayName("PATCH /api/parties/{partyId}/join-requests/{requestId} - 모임 가입 신청 처리")
+ class ActionJoinRequest {
+
+ @Test
+ @DisplayName("200 - 모임장이 가입 신청을 성공적으로 승인한다")
+ void success_actionJoinRequest_approve() throws Exception {
+ // given
+ Member applicant = memberRepository.save(MemberFixture.createMember("지원자", Gender.FEMALE, Level.B, 1020L));
+
+ PartyJoinRequest joinRequest = partyJoinRequestRepository.save(PartyJoinRequest.builder()
+ .party(party)
+ .member(applicant)
+ .status(RequestStatus.PENDING)
+ .build());
+
+ PartyJoinActionDTO.Request actionRequest = new PartyJoinActionDTO.Request(RequestAction.APPROVE);
+ SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+
+ // when & then
+ mockMvc.perform(patch("/api/parties/{partyId}/join-requests/{requestId}", party.getId(), joinRequest.getId())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(actionRequest)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value("COMMON200"));
+
+ // 검증
+ PartyJoinRequest updatedRequest = partyJoinRequestRepository.findById(joinRequest.getId()).orElseThrow();
+ assertThat(updatedRequest.getStatus()).isEqualTo(RequestStatus.APPROVED);
+ boolean isMember = memberPartyRepository.existsByPartyAndMember(party, applicant);
+ assertThat(isMember).isTrue();
+ }
+
+ @Test
+ @DisplayName("200 - 모임장이 가입 신청을 성공적으로 거절한다")
+ void success_actionJoinRequest_reject() throws Exception {
+ // given
+ Member applicant = memberRepository.save(MemberFixture.createMember("탈락자", Gender.FEMALE, Level.B, 1030L));
+
+ PartyJoinRequest joinRequest = partyJoinRequestRepository.save(PartyJoinRequest.builder()
+ .party(party)
+ .member(applicant)
+ .status(RequestStatus.PENDING)
+ .build());
+
+ PartyJoinActionDTO.Request actionRequest = new PartyJoinActionDTO.Request(RequestAction.REJECT);
+ SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+
+ // when & then
+ mockMvc.perform(patch("/api/parties/{partyId}/join-requests/{requestId}", party.getId(), joinRequest.getId())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(actionRequest)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value("COMMON200"));
+
+ // 검증
+ PartyJoinRequest updatedRequest = partyJoinRequestRepository.findById(joinRequest.getId()).orElseThrow();
+ assertThat(updatedRequest.getStatus()).isEqualTo(RequestStatus.REJECTED);
+ boolean isMember = memberPartyRepository.existsByPartyAndMember(party, applicant);
+ assertThat(isMember).isFalse();
+ }
+
+ @Test
+ @DisplayName("403 - 모임장이 아닌 사용자가 가입 신청을 처리하려 하면 INSUFFICIENT_PERMISSION 발생")
+ void fail_actionJoinRequest_notOwner() throws Exception {
+ // given
+ Member applicant = memberRepository.save(MemberFixture.createMember("지원자", Gender.FEMALE, Level.B, 1040L));
+
+ PartyJoinRequest joinRequest = partyJoinRequestRepository.save(PartyJoinRequest.builder()
+ .party(party)
+ .member(applicant)
+ .status(RequestStatus.PENDING)
+ .build());
+
+ PartyJoinActionDTO.Request actionRequest = new PartyJoinActionDTO.Request(RequestAction.APPROVE);
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+
+ // when & then
+ mockMvc.perform(patch("/api/parties/{partyId}/join-requests/{requestId}", party.getId(), joinRequest.getId())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(actionRequest)))
+ .andExpect(status().isForbidden())
+ .andExpect(jsonPath("$.code").value(PartyErrorCode.INSUFFICIENT_PERMISSION.getCode()));
+ }
+
+ @Test
+ @DisplayName("409 - 이미 처리된 가입 신청을 다시 처리하려 할 때 JOIN_REQUEST_ALREADY_ACTIONS 상태 반환")
+ void fail_actionJoinRequest_alreadyHandled() throws Exception {
+ // given
+ Member applicant = memberRepository.save(MemberFixture.createMember("지원자", Gender.FEMALE, Level.B, 1050L));
+
+ PartyJoinRequest joinRequest = partyJoinRequestRepository.save(PartyJoinRequest.builder()
+ .party(party)
+ .member(applicant)
+ .status(RequestStatus.APPROVED)
+ .build());
+
+ PartyJoinActionDTO.Request actionRequest = new PartyJoinActionDTO.Request(RequestAction.APPROVE);
+ SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+
+ // when & then
+ mockMvc.perform(patch("/api/parties/{partyId}/join-requests/{requestId}", party.getId(), joinRequest.getId())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(actionRequest)))
+ .andExpect(status().isConflict())
+ .andExpect(jsonPath("$.code").value(PartyErrorCode.JOIN_REQUEST_ALREADY_ACTIONS.getCode()));
+ }
+
+ @Test
+ @DisplayName("404 - 존재하지 않는 파티의 가입 신청 요청 처리 시 PARTY_NOT_FOUND 에러를 반환한다")
+ void fail_actionJoinRequest_partyNotFound() throws Exception {
+ // given
+ Member applicant = memberRepository.save(MemberFixture.createMember("지원자", Gender.FEMALE, Level.B, 1060L));
+ PartyJoinRequest joinRequest = partyJoinRequestRepository.save(PartyJoinRequest.builder()
+ .party(party)
+ .member(applicant)
+ .status(RequestStatus.PENDING)
+ .build());
+
+ PartyJoinActionDTO.Request actionRequest = new PartyJoinActionDTO.Request(RequestAction.APPROVE);
+ SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+
+ // when & then
+ mockMvc.perform(patch("/api/parties/{partyId}/join-requests/{requestId}", 9999L, joinRequest.getId())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(actionRequest)))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.code").value(PartyErrorCode.PARTY_NOT_FOUND.getCode()));
+ }
+ }
+
+ @Nested
+ @DisplayName("POST /api/parties - 모임 생성")
+ class CreateParty {
+
+ @Test
+ @DisplayName("200 - 모임을 성공적으로 생성하고 DB 저장 상태를 확인한다")
+ void success_createParty() throws Exception {
+ // given
+ PartyCreateDTO.Request request = PartyCreateDTO.Request.builder()
+ .partyName("새로운 통합 모임")
+ .partyType("혼복")
+ .minBirthYear(1990)
+ .maxBirthYear(2000)
+ .activityTime("오전")
+ .addr1("서울특별시")
+ .addr2("강남구")
+ .activityDay(List.of("월", "수"))
+ .price(10000)
+ .joinPrice(5000)
+ .designatedCock("통합테스트콕")
+ .maleLevel(List.of("A조"))
+ .femaleLevel(List.of("B조"))
+ .build();
+
+ SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+
+ // when & then
+ mockMvc.perform(post("/api/parties")
+ .contentType("application/json")
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value("COMMON201"))
+ .andExpect(jsonPath("$.data.partyId").exists());
+
+ // 검증
+ List parties = partyRepository.findAll();
+ Party createdParty = parties.stream()
+ .filter(p -> p.getPartyName().equals("새로운 통합 모임"))
+ .findFirst()
+ .orElseThrow();
+
+ assertThat(createdParty.getOwnerId()).isEqualTo(manager.getId());
+ assertThat(createdParty.getDesignatedCock()).isEqualTo("통합테스트콕");
+ }
+
+ @Test
+ @DisplayName("400 - 본인의 나이가 모임 조건에 맞지 않을 때 에러를 반환한다")
+ void fail_createParty_invalidAgeRange() throws Exception {
+ // given
+ // manager는 1995년생. 모임 조건을 2000~2010으로 설정.
+ PartyCreateDTO.Request request = PartyCreateDTO.Request.builder()
+ .partyName("청년 모임")
+ .partyType("혼복")
+ .minBirthYear(2000)
+ .maxBirthYear(2010)
+ .activityTime("오후")
+ .activityDay(List.of("금"))
+ .addr1("서울특별시")
+ .addr2("강남구")
+ .price(10000)
+ .joinPrice(0)
+ .femaleLevel(List.of("A조"))
+ .maleLevel(List.of("A조"))
+ .designatedCock("청년콕")
+ .build();
+
+ SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+
+ // when & then
+ mockMvc.perform(post("/api/parties")
+ .contentType("application/json")
+ .content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
- .andExpect(jsonPath("$.code").value(PartyErrorCode.PARTY_IS_DELETED.getCode()))
- .andExpect(jsonPath("$.message").value(PartyErrorCode.PARTY_IS_DELETED.getMessage()));
+ .andExpect(jsonPath("$.code").value(PartyErrorCode.AGE_NOT_MATCH.getCode()));
+ }
+
+ @Test
+ @DisplayName("400 - 혼복 모임에서 남자 급수 정보가 누락되었을 때 에러를 반환한다")
+ void fail_createParty_missingMaleLevelInMixDoubles() throws Exception {
+ // given
+ PartyCreateDTO.Request request = PartyCreateDTO.Request.builder()
+ .partyName("혼복 모임")
+ .partyType("혼복")
+ .minBirthYear(1990)
+ .maxBirthYear(2005)
+ .activityTime("오전")
+ .activityDay(List.of("토"))
+ .addr1("서울특별시")
+ .addr2("강남구")
+ .price(10000)
+ .joinPrice(0)
+ .designatedCock("혼복콕")
+ .maleLevel(null)
+ .femaleLevel(List.of("A조"))
+ .build();
+
+ SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+
+ // when & then
+ mockMvc.perform(post("/api/parties")
+ .contentType("application/json")
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(PartyErrorCode.MALE_LEVEL_REQUIRED.getCode()));
+ }
+ }
+
+ @Nested
+ @DisplayName("PATCH /api/parties/{partyId}/members/{memberId}/role - 멤버 역할(부모임장) 설정")
+ class UpdateMemberRole {
+
+ @Test
+ @DisplayName("200 - 모임장이 일반 멤버를 부모임장으로 성공적으로 임명한다")
+ void success_updateMemberRole() throws Exception {
+ // given
+ PartyMemberRoleDTO.Request request = new PartyMemberRoleDTO.Request(Role.PARTY_SUBMANAGER);
+ SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+
+ // when & then
+ mockMvc.perform(patch("/api/parties/{partyId}/members/{memberId}/role", party.getId(), normalMember.getId())
+ .contentType("application/json")
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value("COMMON200"));
+
+ // 검증
+ MemberParty targetMemberParty = memberPartyRepository.findByPartyAndMember(party, normalMember).orElseThrow();
+ assertThat(targetMemberParty.getRole()).isEqualTo(Role.PARTY_SUBMANAGER);
+ }
+
+ @Test
+ @DisplayName("403 - 모임장이 아닌 멤버가 역할 수정을 시도하면 INSUFFICIENT_PERMISSION 예외를 반환한다")
+ void fail_updateMemberRole_notOwner() throws Exception {
+ // given
+ PartyMemberRoleDTO.Request request = new PartyMemberRoleDTO.Request(Role.PARTY_SUBMANAGER);
+ // 일반 멤버가 권한 변경 시도
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+
+ // when & then
+ mockMvc.perform(patch("/api/parties/{partyId}/members/{memberId}/role", party.getId(), normalMember.getId())
+ .contentType("application/json")
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isForbidden())
+ .andExpect(jsonPath("$.code").value(PartyErrorCode.INSUFFICIENT_PERMISSION.getCode()));
+ }
+
+ @Test
+ @DisplayName("403 - 대상자가 모임장인 경우 권한 변경은 실패하며 CANNOT_ASSIGN_TO_OWNER 예외를 반환한다")
+ void fail_updateMemberRole_targetIsOwner() throws Exception {
+ // given
+ PartyMemberRoleDTO.Request request = new PartyMemberRoleDTO.Request(Role.PARTY_MEMBER);
+ SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+
+ // when & then
+ mockMvc.perform(patch("/api/parties/{partyId}/members/{memberId}/role", party.getId(), manager.getId())
+ .contentType("application/json")
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isForbidden())
+ .andExpect(jsonPath("$.code").value(PartyErrorCode.CANNOT_ASSIGN_TO_OWNER.getCode()));
+ }
+
+ @Test
+ @DisplayName("404 - 존재하지 않는 파티의 멤버 역할 수정 시 PARTY_NOT_FOUND 에러를 반환한다")
+ void fail_updateMemberRole_partyNotFound() throws Exception {
+ PartyMemberRoleDTO.Request request = new PartyMemberRoleDTO.Request(Role.PARTY_SUBMANAGER);
+ SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+
+ mockMvc.perform(patch("/api/parties/{partyId}/members/{memberId}/role", 9999L, normalMember.getId())
+ .contentType("application/json")
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.code").value(PartyErrorCode.PARTY_NOT_FOUND.getCode()));
+ }
+
+ @Test
+ @DisplayName("404 - 존재하지 않는 멤버의 역할 수정 시 MEMBER_NOT_FOUND 에러를 반환한다")
+ void fail_updateMemberRole_memberNotFound() throws Exception {
+ PartyMemberRoleDTO.Request request = new PartyMemberRoleDTO.Request(Role.PARTY_SUBMANAGER);
+ SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+
+ mockMvc.perform(patch("/api/parties/{partyId}/members/{memberId}/role", party.getId(), 9999L)
+ .contentType("application/json")
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.code").value(MemberErrorCode.MEMBER_NOT_FOUND.getCode()));
+ }
+ }
+
+ @Nested
+ @DisplayName("POST /api/parties/{partyId}/keywords - 키워드 추가")
+ class AddKeyword {
+
+ @Test
+ @DisplayName("200 - 모임장이 유효한 키워드를 정상적으로 추가한다")
+ void success_addKeyword() throws Exception {
+ // given
+ PartyKeywordDTO.Request request = new PartyKeywordDTO.Request(
+ List.of("친목", "가입비 무료")
+ );
+ SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+
+ // when & then
+ mockMvc.perform(post("/api/parties/{partyId}/keywords", party.getId())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value("COMMON200"));
+
+ // 검증 - DB에 키워드가 실제로 저장됐는지 확인
+ Party updatedParty = partyRepository.findById(party.getId()).orElseThrow();
+ assertThat(updatedParty.getKeywords()).hasSize(2);
+ }
+
+ @Test
+ @DisplayName("403 - 모임장이 아닌 사용자가 키워드를 추가하면 INSUFFICIENT_PERMISSION 발생")
+ void fail_addKeyword_notOwner() throws Exception {
+ // given
+ PartyKeywordDTO.Request request = new PartyKeywordDTO.Request(List.of("친목"));
+ SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname());
+
+ // when & then
+ mockMvc.perform(post("/api/parties/{partyId}/keywords", party.getId())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isForbidden())
+ .andExpect(jsonPath("$.code").value(PartyErrorCode.INSUFFICIENT_PERMISSION.getCode()));
+ }
+
+ @Test
+ @DisplayName("400 - 유효하지 않은 키워드 문자열을 전달하면 INVALID_KEYWORD 발생")
+ void fail_addKeyword_invalidKeyword() throws Exception {
+ // given
+ PartyKeywordDTO.Request request = new PartyKeywordDTO.Request(List.of("존재하지않는키워드"));
+ SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+
+ // when & then
+ mockMvc.perform(post("/api/parties/{partyId}/keywords", party.getId())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(PartyErrorCode.INVALID_KEYWORD.getCode()));
+ }
+
+ @Test
+ @DisplayName("404 - 존재하지 않는 파티에 키워드 추가 시 PARTY_NOT_FOUND 에러를 반환한다")
+ void fail_addKeyword_partyNotFound() throws Exception {
+ PartyKeywordDTO.Request request = new PartyKeywordDTO.Request(List.of("새키워드"));
+ SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname());
+
+ mockMvc.perform(post("/api/parties/{partyId}/keywords", 9999L)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.code").value(PartyErrorCode.PARTY_NOT_FOUND.getCode()));
}
}
}
diff --git a/src/test/java/umc/cockple/demo/domain/party/service/PartyCommandServiceTest.java b/src/test/java/umc/cockple/demo/domain/party/service/PartyCommandServiceTest.java
new file mode 100644
index 000000000..e90511e4c
--- /dev/null
+++ b/src/test/java/umc/cockple/demo/domain/party/service/PartyCommandServiceTest.java
@@ -0,0 +1,1852 @@
+package umc.cockple.demo.domain.party.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.context.ApplicationEventPublisher;
+import org.springframework.test.util.ReflectionTestUtils;
+import umc.cockple.demo.domain.chat.service.ChatRoomService;
+import umc.cockple.demo.domain.file.service.FileService;
+import umc.cockple.demo.domain.member.domain.Member;
+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.MemberPartyRepository;
+import umc.cockple.demo.domain.member.repository.MemberRepository;
+import umc.cockple.demo.domain.notification.service.NotificationCommandService;
+import umc.cockple.demo.domain.party.converter.PartyConverter;
+import umc.cockple.demo.domain.party.domain.Party;
+import umc.cockple.demo.domain.party.domain.PartyAddr;
+import umc.cockple.demo.domain.party.domain.PartyInvitation;
+import umc.cockple.demo.domain.party.domain.PartyJoinRequest;
+import umc.cockple.demo.domain.party.dto.*;
+import umc.cockple.demo.domain.party.enums.ParticipationType;
+import umc.cockple.demo.domain.party.enums.PartyStatus;
+import umc.cockple.demo.domain.party.enums.RequestAction;
+import umc.cockple.demo.domain.party.enums.RequestStatus;
+import umc.cockple.demo.domain.party.events.PartyMemberJoinedEvent;
+import umc.cockple.demo.domain.party.exception.PartyErrorCode;
+import umc.cockple.demo.domain.party.exception.PartyException;
+import umc.cockple.demo.domain.party.repository.PartyAddrRepository;
+import umc.cockple.demo.domain.party.repository.PartyInvitationRepository;
+import umc.cockple.demo.domain.party.repository.PartyJoinRequestRepository;
+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.fixture.MemberFixture;
+import umc.cockple.demo.support.fixture.PartyFixture;
+
+import java.time.LocalDate;
+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.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class PartyCommandServiceTest {
+
+ @InjectMocks
+ private PartyCommandServiceImpl partyCommandService;
+
+ @Mock
+ private PartyRepository partyRepository;
+ @Mock
+ private MemberRepository memberRepository;
+ @Mock
+ private NotificationCommandService notificationCommandService;
+ @Mock
+ private PartyAddrRepository partyAddrRepository;
+ @Mock
+ private MemberPartyRepository memberPartyRepository;
+ @Mock
+ private ChatRoomService chatRoomService;
+ @Mock
+ private ApplicationEventPublisher applicationEventPublisher;
+ @Mock
+ private PartyJoinRequestRepository partyJoinRequestRepository;
+ @Mock
+ private PartyInvitationRepository partyInvitationRepository;
+ @Mock
+ private FileService fileService;
+
+ private PartyConverter partyConverter;
+
+ @BeforeEach
+ void setUp() {
+ partyConverter = new PartyConverter(fileService);
+ ReflectionTestUtils.setField(partyCommandService, "partyConverter", partyConverter);
+ }
+
+ @Nested
+ @DisplayName("leaveParty")
+ class LeaveParty {
+
+ @Test
+ @DisplayName("성공 - 일반 멤버가 모임을 탈퇴한다")
+ void success_leaveParty() {
+ // given
+ Long partyId = 1L;
+ Long memberId = 10L;
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남");
+ Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, 1L);
+ ReflectionTestUtils.setField(owner, "id", 1L);
+ Party party = PartyFixture.createParty("탈퇴 테스트 모임", owner.getId(), addr);
+ ReflectionTestUtils.setField(party, "id", partyId);
+
+ Member member = MemberFixture.createMember("일반멤버", Gender.MALE, Level.A, 10L);
+ ReflectionTestUtils.setField(member, "id", memberId);
+
+ MemberParty memberParty = MemberFixture.createMemberParty(party, member, Role.PARTY_MEMBER);
+
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+ given(memberRepository.findById(memberId)).willReturn(Optional.of(member));
+ given(memberPartyRepository.findByPartyAndMember(party, member)).willReturn(Optional.of(memberParty));
+
+ // when
+ partyCommandService.leaveParty(partyId, memberId);
+
+ // then
+ verify(memberPartyRepository).delete(memberParty);
+ verify(chatRoomService).leavePartyChatRoom(partyId, memberId);
+ verify(applicationEventPublisher).publishEvent(any(PartyMemberJoinedEvent.class));
+ }
+
+ @Test
+ @DisplayName("실패 - 존재하지 않는 모임인 경우 PARTY_NOT_FOUND 예외가 발생한다")
+ void fail_leaveParty_partyNotFound() {
+ // given
+ given(partyRepository.findById(999L)).willReturn(Optional.empty());
+
+ // when & then
+ assertThatThrownBy(() -> partyCommandService.leaveParty(999L, 1L))
+ .isInstanceOf(PartyException.class)
+ .satisfies(
+ e -> assertThat(((PartyException) e).getCode()).isEqualTo(PartyErrorCode.PARTY_NOT_FOUND));
+ }
+
+ @Test
+ @DisplayName("실패 - 삭제된 모임인 경우 PARTY_IS_DELETED 예외가 발생한다")
+ void fail_leaveParty_partyDeleted() {
+ // given
+ Long partyId = 1L;
+ PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남");
+ Party party = PartyFixture.createParty("삭제된 모임", 1L, addr);
+ party.delete();
+
+ Member member = MemberFixture.createMember("일반멤버", Gender.MALE, Level.A, 1L);
+ ReflectionTestUtils.setField(member, "id", 1L);
+
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+ given(memberRepository.findById(1L)).willReturn(Optional.of(member));
+
+ // when & then
+ assertThatThrownBy(() -> partyCommandService.leaveParty(partyId, 1L))
+ .isInstanceOf(PartyException.class)
+ .satisfies(
+ e -> assertThat(((PartyException) e).getCode()).isEqualTo(PartyErrorCode.PARTY_IS_DELETED));
+ }
+
+ @Test
+ @DisplayName("실패 - 모임장이 탈퇴하려 할 경우 INVALID_ACTION_FOR_OWNER 예외가 발생한다")
+ void fail_leaveParty_isOwner() {
+ // given
+ Long partyId = 1L;
+ Long ownerId = 1L;
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남");
+ Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, 1L);
+ ReflectionTestUtils.setField(owner, "id", ownerId);
+ Party party = PartyFixture.createParty("탈퇴 테스트 모임", owner.getId(), addr);
+ ReflectionTestUtils.setField(party, "id", partyId);
+
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+ given(memberRepository.findById(ownerId)).willReturn(Optional.of(owner));
+
+ // when & then
+ assertThatThrownBy(() -> partyCommandService.leaveParty(partyId, ownerId))
+ .isInstanceOf(PartyException.class)
+ .satisfies(e -> assertThat(((PartyException) e).getCode())
+ .isEqualTo(PartyErrorCode.INVALID_ACTION_FOR_OWNER));
+ }
+
+ @Test
+ @DisplayName("실패 - 부모임장이 탈퇴하려 할 경우 INVALID_ACTION_FOR_SUBOWNER 예외가 발생한다")
+ void fail_leaveParty_isSubOwner() {
+ // given
+ Long partyId = 1L;
+ Long subManagerId = 2L;
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남");
+ Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, 1L);
+ ReflectionTestUtils.setField(owner, "id", 1L);
+ Party party = PartyFixture.createParty("탈퇴 테스트 모임", owner.getId(), addr);
+ ReflectionTestUtils.setField(party, "id", partyId);
+
+ Member subManager = MemberFixture.createMember("부모임장", Gender.MALE, Level.A, 2L);
+ ReflectionTestUtils.setField(subManager, "id", subManagerId);
+
+ MemberParty subManagerParty = MemberFixture.createMemberParty(party, subManager, Role.PARTY_SUBMANAGER);
+
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+ given(memberRepository.findById(subManagerId)).willReturn(Optional.of(subManager));
+ given(memberPartyRepository.findByPartyIdAndRole(partyId, Role.PARTY_SUBMANAGER))
+ .willReturn(Optional.of(subManagerParty));
+
+ // when & then
+ assertThatThrownBy(() -> partyCommandService.leaveParty(partyId, subManagerId))
+ .isInstanceOf(PartyException.class)
+ .satisfies(e -> assertThat(((PartyException) e).getCode())
+ .isEqualTo(PartyErrorCode.INVALID_ACTION_FOR_SUBOWNER));
+ }
+
+ @Test
+ @DisplayName("실패 - 모임 멤버가 아닌 경우 NOT_MEMBER 예외가 발생한다")
+ void fail_leaveParty_notMember() {
+ // given
+ Long partyId = 1L;
+ Long memberId = 10L;
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남");
+ Party party = PartyFixture.createParty("탈퇴 테스트 모임", 1L, addr);
+ ReflectionTestUtils.setField(party, "id", partyId);
+ Member member = MemberFixture.createMember("외부인", Gender.MALE, Level.A, 10L);
+ ReflectionTestUtils.setField(member, "id", memberId);
+
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+ given(memberRepository.findById(memberId)).willReturn(Optional.of(member));
+ given(memberPartyRepository.findByPartyAndMember(party, member)).willReturn(Optional.empty());
+
+ // when & then
+ assertThatThrownBy(() -> partyCommandService.leaveParty(partyId, memberId))
+ .isInstanceOf(PartyException.class)
+ .satisfies(e -> assertThat(((PartyException) e).getCode()).isEqualTo(PartyErrorCode.NOT_MEMBER));
+ }
+
+ @Test
+ @DisplayName("실패 - 존재하지 않는 회원인 경우 MEMBER_NOT_FOUND 예외가 발생한다")
+ void fail_leaveParty_memberNotFound() {
+ // given
+ Long partyId = 1L;
+ PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남");
+ Party party = PartyFixture.createParty("모임명", 1L, addr);
+ ReflectionTestUtils.setField(party, "id", partyId);
+
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+ given(memberRepository.findById(10L)).willReturn(Optional.empty());
+
+ // when & then
+ assertThatThrownBy(() -> partyCommandService.leaveParty(partyId, 10L))
+ .isInstanceOf(MemberException.class)
+ .satisfies(e -> assertThat(((MemberException) e).getCode()).isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND));
+ }
+ }
+
+ @Nested
+ @DisplayName("createJoinRequest")
+ class CreateJoinRequest {
+
+ @Test
+ @DisplayName("성공 - 사용자가 특정 모임에 가입 신청을 성공적으로 완료한다")
+ void success_createJoinRequest() {
+ // given
+ Long partyId = 1L;
+ Long memberId = 1L;
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남");
+ Party party = PartyFixture.createParty("가입 신청 모임", 10L, addr);
+ Member member = MemberFixture.createMember("지원자", Gender.MALE, Level.B, 1L, LocalDate.of(1995, 1, 1));
+ ReflectionTestUtils.setField(member, "id", memberId);
+
+ given(memberRepository.findById(memberId)).willReturn(Optional.of(member));
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+ given(memberPartyRepository.existsByPartyAndMember(party, member)).willReturn(false);
+ given(partyJoinRequestRepository.existsByPartyAndMemberAndStatus(party, member, RequestStatus.PENDING)).willReturn(false);
+ given(partyJoinRequestRepository.save(any(PartyJoinRequest.class))).willAnswer(invocation -> invocation.getArgument(0));
+
+ // when
+ PartyJoinCreateDTO.Response response = partyCommandService.createJoinRequest(partyId, memberId);
+
+ // then
+ assertThat(response).isNotNull();
+ verify(partyJoinRequestRepository).save(any(PartyJoinRequest.class));
+ }
+
+ @Test
+ @DisplayName("실패 - 이미 해당 모임의 멤버인 경우 ALREADY_MEMBER 예외가 발생한다")
+ void fail_createJoinRequest_alreadyMember() {
+ // given
+ Long partyId = 1L;
+ Long memberId = 1L;
+
+ Party party = PartyFixture.createParty("가입 신청 모임", 10L, null);
+ Member member = MemberFixture.createMember("지원자", Gender.MALE, Level.B, 1L);
+
+ given(memberRepository.findById(memberId)).willReturn(Optional.of(member));
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+ given(memberPartyRepository.existsByPartyAndMember(party, member)).willReturn(true);
+
+ // when & then
+ assertThatThrownBy(() -> partyCommandService.createJoinRequest(partyId, memberId))
+ .isInstanceOf(PartyException.class)
+ .satisfies(e -> assertThat(((PartyException) e).getCode()).isEqualTo(PartyErrorCode.ALREADY_MEMBER));
+ }
+
+ @Test
+ @DisplayName("실패 - 대기 중인 가입 신청이 이미 존재하는 경우 JOIN_REQUEST_ALREADY_EXISTS 예외가 발생한다")
+ void fail_createJoinRequest_alreadyRequested() {
+ // given
+ Long partyId = 1L;
+ Long memberId = 1L;
+
+ Party party = PartyFixture.createParty("가입 신청 모임", 10L, null);
+ Member member = MemberFixture.createMember("지원자", Gender.MALE, Level.B, 1L);
+
+ given(memberRepository.findById(memberId)).willReturn(Optional.of(member));
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+ given(memberPartyRepository.existsByPartyAndMember(party, member)).willReturn(false);
+ given(partyJoinRequestRepository.existsByPartyAndMemberAndStatus(party, member, RequestStatus.PENDING)).willReturn(true);
+
+ // when & then
+ assertThatThrownBy(() -> partyCommandService.createJoinRequest(partyId, memberId))
+ .isInstanceOf(PartyException.class)
+ .satisfies(e -> assertThat(((PartyException) e).getCode()).isEqualTo(PartyErrorCode.JOIN_REQUEST_ALREADY_EXISTS));
+ }
+
+ @Test
+ @DisplayName("실패 - 모임 유형에 맞지 않는 성별인 경우 GENDER_NOT_MATCH 예외가 발생한다")
+ void fail_createJoinRequest_genderMismatch() {
+ // given
+ Long partyId = 1L;
+ Long memberId = 1L;
+
+ // 여복 모임 생성
+ Party party = Party.builder()
+ .partyName("여복 모임")
+ .partyType(ParticipationType.WOMEN_DOUBLES)
+ .status(PartyStatus.ACTIVE)
+ .ownerId(10L)
+ .build();
+ Member member = MemberFixture.createMember("남자지원자", Gender.MALE, Level.B, 1L); // 남성 지원
+
+ given(memberRepository.findById(memberId)).willReturn(Optional.of(member));
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+ given(memberPartyRepository.existsByPartyAndMember(party, member)).willReturn(false);
+ given(partyJoinRequestRepository.existsByPartyAndMemberAndStatus(party, member, RequestStatus.PENDING)).willReturn(false);
+
+ // when & then
+ assertThatThrownBy(() -> partyCommandService.createJoinRequest(partyId, memberId))
+ .isInstanceOf(PartyException.class)
+ .satisfies(e -> assertThat(((PartyException) e).getCode()).isEqualTo(PartyErrorCode.GENDER_NOT_MATCH));
+ }
+
+ @Test
+ @DisplayName("실패 - 모임의 나이 조건에 맞지 않는 경우 AGE_NOT_MATCH 예외가 발생한다")
+ void fail_createJoinRequest_ageMismatch() {
+ // given
+ Long partyId = 1L;
+ Long memberId = 1L;
+
+ // 1990~2000년생 모임
+ Party party = Party.builder()
+ .partyName("나이 제한 모임")
+ .minBirthYear(1990)
+ .maxBirthYear(2000)
+ .status(PartyStatus.ACTIVE)
+ .ownerId(10L)
+ .build();
+ // 1980년생 지원자 (범위 밖)
+ Member member = MemberFixture.createMember("나이많은지원자", Gender.MALE, Level.B, 1L, LocalDate.of(1980, 1, 1));
+
+ given(memberRepository.findById(memberId)).willReturn(Optional.of(member));
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+ given(memberPartyRepository.existsByPartyAndMember(party, member)).willReturn(false);
+ given(partyJoinRequestRepository.existsByPartyAndMemberAndStatus(party, member, RequestStatus.PENDING)).willReturn(false);
+
+ // when & then
+ assertThatThrownBy(() -> partyCommandService.createJoinRequest(partyId, memberId))
+ .isInstanceOf(PartyException.class)
+ .satisfies(e -> assertThat(((PartyException) e).getCode()).isEqualTo(PartyErrorCode.AGE_NOT_MATCH));
+ }
+
+ @Test
+ @DisplayName("실패 - 존재하지 않는 파티인 경우 PARTY_NOT_FOUND 예외 발생")
+ void fail_createJoinRequest_partyNotFound() {
+ // given
+ Member member = MemberFixture.createMember("사용자", Gender.MALE, Level.B, 1L);
+ ReflectionTestUtils.setField(member, "id", 1L);
+ given(memberRepository.findById(1L)).willReturn(Optional.of(member));
+ given(partyRepository.findById(999L)).willReturn(Optional.empty());
+
+ // when & then
+ assertThatThrownBy(() -> partyCommandService.createJoinRequest(999L, 1L))
+ .isInstanceOf(PartyException.class)
+ .satisfies(e -> assertThat(((PartyException) e).getCode()).isEqualTo(PartyErrorCode.PARTY_NOT_FOUND));
+ }
+
+ @Test
+ @DisplayName("실패 - 존재하지 않는 회원인 경우 MEMBER_NOT_FOUND 예외 발생")
+ void fail_createJoinRequest_memberNotFound() {
+ // given
+ given(memberRepository.findById(1L)).willReturn(Optional.empty());
+
+ // when & then
+ assertThatThrownBy(() -> partyCommandService.createJoinRequest(1L, 1L))
+ .isInstanceOf(MemberException.class)
+ .satisfies(e -> assertThat(((MemberException) e).getCode()).isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND));
+ }
+ }
+
+ @Nested
+ @DisplayName("createParty - 모임 생성")
+ class CreateParty {
+
+ @Test
+ @DisplayName("성공 - 올바른 데이터 입력 시 모임이 생성되고 채팅방이 개설된다")
+ void success_createParty() {
+ // given
+ Long memberId = 1L;
+ PartyCreateDTO.Request request = PartyCreateDTO.Request.builder()
+ .partyName("테스트 모임")
+ .partyType("혼복")
+ .minBirthYear(1990)
+ .maxBirthYear(2000)
+ .activityTime("오전")
+ .addr1("서울")
+ .addr2("강남")
+ .activityDay(List.of("월", "수"))
+ .price(10000)
+ .joinPrice(5000)
+ .designatedCock("테스트콕")
+ .maleLevel(List.of("A조"))
+ .femaleLevel(List.of("B조"))
+ .build();
+
+ Member owner = Member.builder()
+ .id(memberId)
+ .gender(Gender.MALE)
+ .level(Level.A)
+ .birth(LocalDate.of(1995, 1, 1))
+ .build();
+
+ PartyAddr partyAddr = PartyAddr.builder().id(1L).build();
+ Party savedParty = Party.builder().id(1L).partyName("테스트 모임").build();
+
+ given(memberRepository.findById(memberId)).willReturn(Optional.of(owner));
+ given(partyAddrRepository.findByAddr1AndAddr2(anyString(), anyString())).willReturn(Optional.of(partyAddr));
+ given(partyRepository.save(any(Party.class))).willReturn(savedParty);
+
+ // when
+ PartyCreateDTO.Response response = partyCommandService.createParty(memberId, request);
+
+ // then
+ assertThat(response).isNotNull();
+ assertThat(response.partyId()).isEqualTo(1L);
+ verify(partyRepository, times(1)).save(any(Party.class));
+ verify(chatRoomService, times(1)).createPartyChatRoom(any(Party.class), eq(owner));
+ }
+
+ @Test
+ @DisplayName("실패 - 혼복 모임 생성 시 남자 급수 정보가 누락되면 MALE_LEVEL_REQUIRED 예외가 발생한다")
+ void fail_createParty_mixDoubles_maleLevelMissing() {
+ // given
+ Long memberId = 1L;
+ PartyCreateDTO.Request request = PartyCreateDTO.Request.builder()
+ .partyName("혼복 모임")
+ .partyType("혼복")
+ .minBirthYear(1990)
+ .maxBirthYear(2000)
+ .activityTime("오전")
+ .activityDay(List.of("월"))
+ .femaleLevel(List.of("A조"))
+ .maleLevel(null) // 누락
+ .build();
+
+ Member owner = Member.builder()
+ .id(memberId)
+ .gender(Gender.MALE)
+ .birth(LocalDate.of(1995, 1, 1))
+ .build();
+
+ given(memberRepository.findById(memberId)).willReturn(Optional.of(owner));
+
+ // when & then
+ PartyException exception = assertThrows(PartyException.class,
+ () -> partyCommandService.createParty(memberId, request));
+ assertThat(exception.getCode()).isEqualTo(PartyErrorCode.MALE_LEVEL_REQUIRED);
+ }
+
+ @Test
+ @DisplayName("실패 - 여복 모임 생성 시 남자 급수 정보가 포함되면 MALE_LEVEL_NOT_NEEDED 예외가 발생한다")
+ void fail_createParty_womenDoubles_maleLevelProvided() {
+ // given
+ Long memberId = 1L;
+ PartyCreateDTO.Request request = PartyCreateDTO.Request.builder()
+ .partyName("여복 모임")
+ .partyType("여복")
+ .minBirthYear(1990)
+ .maxBirthYear(2010)
+ .activityTime("오전")
+ .activityDay(List.of("토"))
+ .femaleLevel(List.of("A조"))
+ .maleLevel(List.of("A조")) // 포함됨
+ .build();
+
+ Member owner = Member.builder()
+ .id(memberId)
+ .gender(Gender.FEMALE)
+ .birth(LocalDate.of(2000, 1, 1))
+ .build();
+
+ given(memberRepository.findById(memberId)).willReturn(Optional.of(owner));
+
+ // when & then
+ PartyException exception = assertThrows(PartyException.class,
+ () -> partyCommandService.createParty(memberId, request));
+ assertThat(exception.getCode()).isEqualTo(PartyErrorCode.MALE_LEVEL_NOT_NEEDED);
+ }
+
+ @Test
+ @DisplayName("실패 - 모임 유형의 성별 조건과 생성자의 성별이 맞지 않으면 GENDER_NOT_MATCH 예외가 발생한다")
+ void fail_createParty_genderMismatch() {
+ // given
+ Long memberId = 1L;
+ PartyCreateDTO.Request request = PartyCreateDTO.Request.builder()
+ .partyName("여복 모임")
+ .partyType("여복")
+ .minBirthYear(1990)
+ .maxBirthYear(2010)
+ .activityTime("오전")
+ .activityDay(List.of("일"))
+ .femaleLevel(List.of("A조"))
+ .build();
+
+ Member maleOwner = Member.builder()
+ .id(memberId)
+ .gender(Gender.MALE) // 남성이 여복 모임 생성 시도
+ .birth(LocalDate.of(2000, 1, 1))
+ .build();
+
+ given(memberRepository.findById(memberId)).willReturn(Optional.of(maleOwner));
+
+ // when & then
+ PartyException exception = assertThrows(PartyException.class,
+ () -> partyCommandService.createParty(memberId, request));
+ assertThat(exception.getCode()).isEqualTo(PartyErrorCode.GENDER_NOT_MATCH);
+ }
+
+ @Test
+ @DisplayName("실패 - 생성자의 나이가 모임의 나이 제한 범위를 벗어나면 AGE_NOT_MATCH 예외가 발생한다")
+ void fail_createParty_ageMismatch() {
+ // given
+ Long memberId = 1L;
+ PartyCreateDTO.Request request = PartyCreateDTO.Request.builder()
+ .partyName("청년 모임")
+ .partyType("혼복")
+ .minBirthYear(2000)
+ .maxBirthYear(2010)
+ .maleLevel(List.of("A조"))
+ .femaleLevel(List.of("A조"))
+ .activityTime("오후")
+ .activityDay(List.of("금"))
+ .build();
+
+ Member oldOwner = Member.builder()
+ .id(memberId)
+ .gender(Gender.MALE)
+ .birth(LocalDate.of(1980, 1, 1)) // 80년생이 00~10년생 모임 생성 시도
+ .build();
+
+ given(memberRepository.findById(memberId)).willReturn(Optional.of(oldOwner));
+
+ // when & then
+ PartyException exception = assertThrows(PartyException.class,
+ () -> partyCommandService.createParty(memberId, request));
+ assertThat(exception.getCode()).isEqualTo(PartyErrorCode.AGE_NOT_MATCH);
+ }
+
+ @Test
+ @DisplayName("실패 - 존재하지 않는 회원인 경우 MEMBER_NOT_FOUND 예외 발생")
+ void fail_createParty_memberNotFound() {
+ // given
+ Long memberId = 1L;
+ PartyCreateDTO.Request request = PartyCreateDTO.Request.builder()
+ .partyName("테스트 모임")
+ .partyType("혼복")
+ .activityTime("오전")
+ .addr1("서울")
+ .addr2("강남")
+ .activityDay(List.of("월", "수"))
+ .price(10000)
+ .joinPrice(5000)
+ .designatedCock("테스트콕")
+ .maleLevel(List.of("A조"))
+ .femaleLevel(List.of("B조"))
+ .build();
+
+ given(memberRepository.findById(memberId)).willReturn(Optional.empty());
+
+ // when & then
+ assertThatThrownBy(() -> partyCommandService.createParty(memberId, request))
+ .isInstanceOf(MemberException.class)
+ .satisfies(e -> assertThat(((MemberException) e).getCode()).isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND));
+ }
+ }
+
+ @Nested
+ @DisplayName("updateParty")
+ class UpdateParty {
+
+ @Test
+ @DisplayName("성공 - 모임장이 모임 정보를 정상적으로 수정한다")
+ void success_updateParty() {
+ // given
+ Long partyId = 1L;
+ Long memberId = 1L;
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남");
+ Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, 1L);
+ ReflectionTestUtils.setField(owner, "id", memberId);
+
+ Party party = PartyFixture.createParty("기존 모임명", owner.getId(), addr);
+ ReflectionTestUtils.setField(party, "id", partyId);
+
+ PartyUpdateDTO.Request request = PartyUpdateDTO.Request.builder()
+ .activityDay(List.of("토", "일"))
+ .activityTime("오전")
+ .designatedCock("새 콕")
+ .joinPrice(0)
+ .price(10000)
+ .content("새로운 내용")
+ .build();
+
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+ given(memberRepository.findById(memberId)).willReturn(Optional.of(owner));
+
+ // when
+ partyCommandService.updateParty(partyId, memberId, request);
+
+ // then
+ assertThat(party.getDesignatedCock()).isEqualTo("새 콕");
+ assertThat(party.getActiveDays().size()).isEqualTo(2); // 토, 일
+ assertThat(party.getJoinPrice()).isEqualTo(0);
+ assertThat(party.getPrice()).isEqualTo(10000);
+ assertThat(party.getContent()).isEqualTo("새로운 내용");
+
+ verify(notificationCommandService, times(1)).createNotification(any());
+ }
+
+ @Test
+ @DisplayName("실패 - 조회된 모임이 없는 경우 PARTY_NOT_FOUND 예외 발생")
+ void fail_updateParty_partyNotFound() {
+ // given
+ Long partyId = 99L;
+ Long memberId = 1L;
+ PartyUpdateDTO.Request request = PartyUpdateDTO.Request.builder().build();
+
+ given(partyRepository.findById(partyId)).willReturn(Optional.empty());
+
+ // when & then
+ PartyException exception = assertThrows(PartyException.class,
+ () -> partyCommandService.updateParty(partyId, memberId, request));
+ assertThat(exception.getCode()).isEqualTo(PartyErrorCode.PARTY_NOT_FOUND);
+ }
+
+ @Test
+ @DisplayName("실패 - 모임장이 아닌 사용자가 수정을 시도할 경우 INSUFFICIENT_PERMISSION 예외 발생")
+ void fail_updateParty_insufficientPermission() {
+ // given
+ Long partyId = 1L;
+ Long memberId = 10L; // 일반 멤버 (ownerId=1 과 다름)
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남");
+ Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, 1L);
+ ReflectionTestUtils.setField(owner, "id", 1L);
+
+ Member normalMember = MemberFixture.createMember("일반멤버", Gender.MALE, Level.A, 2L);
+ ReflectionTestUtils.setField(normalMember, "id", memberId);
+
+ Party party = PartyFixture.createParty("모임명", owner.getId(), addr);
+ ReflectionTestUtils.setField(party, "id", partyId);
+
+ PartyUpdateDTO.Request request = PartyUpdateDTO.Request.builder()
+ .activityTime("오전").build();
+
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+ given(memberRepository.findById(memberId)).willReturn(Optional.of(normalMember));
+
+ // when & then
+ PartyException exception = assertThrows(PartyException.class,
+ () -> partyCommandService.updateParty(partyId, memberId, request));
+ assertThat(exception.getCode()).isEqualTo(PartyErrorCode.INSUFFICIENT_PERMISSION);
+ }
+
+ @Test
+ @DisplayName("실패 - 존재하지 않는 회원인 경우 MEMBER_NOT_FOUND 예외 발생")
+ void fail_updateParty_memberNotFound() {
+ // given
+ Long partyId = 1L;
+ Long memberId = 1L;
+ Party party = PartyFixture.createParty("모임명", 1L, null);
+ PartyUpdateDTO.Request request = PartyUpdateDTO.Request.builder()
+ .activityTime("오전")
+ .build();
+
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+ given(memberRepository.findById(memberId)).willReturn(Optional.empty());
+
+ // when & then
+ assertThatThrownBy(() -> partyCommandService.updateParty(partyId, memberId, request))
+ .isInstanceOf(MemberException.class)
+ .satisfies(e -> assertThat(((MemberException) e).getCode()).isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND));
+ }
+ }
+
+ @Nested
+ @DisplayName("deleteParty")
+ class DeleteParty {
+
+ @Test
+ @DisplayName("성공 - 모임장이 모임을 정상적으로 삭제(비활성화)한다")
+ void success_deleteParty() {
+ // given
+ Long partyId = 1L;
+ Long ownerId = 1L;
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남");
+ Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, ownerId);
+ ReflectionTestUtils.setField(owner, "id", ownerId);
+
+ Party party = PartyFixture.createParty("삭제할 모임", owner.getId(), addr);
+ ReflectionTestUtils.setField(party, "id", partyId);
+
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+ given(memberRepository.findById(ownerId)).willReturn(Optional.of(owner));
+
+ // when
+ partyCommandService.deleteParty(partyId, ownerId);
+
+ // then
+ assertThat(party.getStatus()).isEqualTo(PartyStatus.INACTIVE);
+ }
+
+ @Test
+ @DisplayName("실패 - 모임장이 아닌 멤버가 모임 삭제를 시도할 경우 INSUFFICIENT_PERMISSION 발생")
+ void fail_deleteParty_notOwner() {
+ // given
+ Long partyId = 1L;
+ Long ownerId = 1L;
+ Long notOwnerId = 2L;
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남");
+ Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, ownerId);
+ ReflectionTestUtils.setField(owner, "id", ownerId);
+
+ Member notOwner = MemberFixture.createMember("일반멤버", Gender.MALE, Level.A, notOwnerId);
+ ReflectionTestUtils.setField(notOwner, "id", notOwnerId);
+
+ Party party = PartyFixture.createParty("삭제할 모임", owner.getId(), addr);
+ ReflectionTestUtils.setField(party, "id", partyId);
+
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+ given(memberRepository.findById(notOwnerId)).willReturn(Optional.of(notOwner));
+
+ // when & then
+ PartyException exception = assertThrows(PartyException.class,
+ () -> partyCommandService.deleteParty(partyId, notOwnerId));
+ assertThat(exception.getCode()).isEqualTo(PartyErrorCode.INSUFFICIENT_PERMISSION);
+ }
+
+ @Test
+ @DisplayName("실패 - 이미 삭제된 모임을 삭제하려고 시도할 경우 PARTY_IS_DELETED 발생")
+ void fail_deleteParty_partyDeleted() {
+ // given
+ Long partyId = 1L;
+ Long ownerId = 1L;
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남");
+ Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, ownerId);
+ ReflectionTestUtils.setField(owner, "id", ownerId);
+
+ Party party = PartyFixture.createParty("이미 삭제된 모임", owner.getId(), addr);
+ ReflectionTestUtils.setField(party, "id", partyId);
+ party.delete(); // 상태를 INACTIVE로 변경
+
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+ given(memberRepository.findById(ownerId)).willReturn(Optional.of(owner));
+
+ // when & then
+ PartyException exception = assertThrows(PartyException.class,
+ () -> partyCommandService.deleteParty(partyId, ownerId));
+ assertThat(exception.getCode()).isEqualTo(PartyErrorCode.PARTY_IS_DELETED);
+ }
+
+ @Test
+ @DisplayName("실패 - 조회된 모임이 존재하지 않을 경우 PARTY_NOT_FOUND 발생")
+ void fail_deleteParty_partyNotFound() {
+ // given
+ Long invalidId = 999L;
+ given(partyRepository.findById(invalidId)).willReturn(Optional.empty());
+
+ // when & then
+ PartyException exception = assertThrows(PartyException.class,
+ () -> partyCommandService.deleteParty(invalidId, 1L));
+ assertThat(exception.getCode()).isEqualTo(PartyErrorCode.PARTY_NOT_FOUND);
+ }
+
+ @Test
+ @DisplayName("실패 - 존재하지 않는 회원인 경우 MEMBER_NOT_FOUND 예외 발생")
+ void fail_deleteParty_memberNotFound() {
+ // given
+ Long partyId = 1L;
+ Long memberId = 1L;
+ Party party = PartyFixture.createParty("모임명", 1L, null);
+
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+ given(memberRepository.findById(memberId)).willReturn(Optional.empty());
+
+ // when & then
+ assertThatThrownBy(() -> partyCommandService.deleteParty(partyId, memberId))
+ .isInstanceOf(MemberException.class)
+ .satisfies(e -> assertThat(((MemberException) e).getCode()).isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND));
+ }
+ }
+
+ @Nested
+ @DisplayName("updateMemberRole")
+ class UpdateMemberRole {
+
+ @Test
+ @DisplayName("성공 - 모임장이 일반 멤버를 부모임장으로 지정하면 기존 부모임장은 일반 멤버로 강등되고 새 부모임장이 지정된다")
+ void success_updateMemberRole() {
+ // given
+ Long partyId = 1L;
+ Long currentOwnerId = 1L;
+ Long targetMemberId = 10L;
+ Long oldSubManagerId = 20L;
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남");
+ Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, currentOwnerId);
+ ReflectionTestUtils.setField(owner, "id", currentOwnerId);
+
+ Member targetMember = MemberFixture.createMember("일반멤버", Gender.MALE, Level.A, targetMemberId);
+ ReflectionTestUtils.setField(targetMember, "id", targetMemberId);
+
+ Member oldSubManager = MemberFixture.createMember("기존부모임장", Gender.MALE, Level.A, oldSubManagerId);
+ ReflectionTestUtils.setField(oldSubManager, "id", oldSubManagerId);
+
+ Party party = PartyFixture.createParty("모임명", owner.getId(), addr);
+ ReflectionTestUtils.setField(party, "id", partyId);
+
+ MemberParty targetMemberParty = MemberFixture.createMemberParty(party, targetMember, Role.PARTY_MEMBER);
+ MemberParty oldSubManagerParty = MemberFixture.createMemberParty(party, oldSubManager, Role.PARTY_SUBMANAGER);
+
+ PartyMemberRoleDTO.Request request = new PartyMemberRoleDTO.Request(Role.PARTY_SUBMANAGER);
+
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+ given(memberRepository.findById(targetMemberId)).willReturn(Optional.of(targetMember));
+ given(memberPartyRepository.findByPartyAndMember(party, targetMember)).willReturn(Optional.of(targetMemberParty));
+ given(memberPartyRepository.findByPartyIdAndRole(partyId, Role.PARTY_SUBMANAGER)).willReturn(Optional.of(oldSubManagerParty));
+ given(memberPartyRepository.findAllByPartyIdWithMember(partyId)).willReturn(List.of(targetMemberParty, oldSubManagerParty));
+
+ // when
+ partyCommandService.updateMemberRole(partyId, targetMemberId, currentOwnerId, request);
+
+ // then
+ assertThat(targetMemberParty.getRole()).isEqualTo(Role.PARTY_SUBMANAGER);
+ assertThat(oldSubManagerParty.getRole()).isEqualTo(Role.PARTY_MEMBER);
+ verify(notificationCommandService, times(4)).createNotification(any());
+ }
+
+ @Test
+ @DisplayName("실패 - 이미 요청한 역할과 같은 역할인 경우 변경 없이 반환된다")
+ void fail_updateMemberRole_sameRole() {
+ // given
+ Long partyId = 1L;
+ Long ownerId = 1L;
+ Long targetId = 10L;
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남");
+ Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, ownerId);
+ ReflectionTestUtils.setField(owner, "id", ownerId);
+
+ Member targetMember = MemberFixture.createMember("타겟", Gender.MALE, Level.A, targetId);
+ ReflectionTestUtils.setField(targetMember, "id", targetId);
+
+ Party party = PartyFixture.createParty("모임명", owner.getId(), addr);
+ ReflectionTestUtils.setField(party, "id", partyId);
+
+ MemberParty targetMemberParty = spy(MemberFixture.createMemberParty(party, targetMember, Role.PARTY_SUBMANAGER));
+ PartyMemberRoleDTO.Request request = new PartyMemberRoleDTO.Request(Role.PARTY_SUBMANAGER);
+
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+ given(memberRepository.findById(targetId)).willReturn(Optional.of(targetMember));
+ given(memberPartyRepository.findByPartyAndMember(party, targetMember)).willReturn(Optional.of(targetMemberParty));
+
+ // when
+ partyCommandService.updateMemberRole(partyId, targetId, ownerId, request);
+
+ // then
+ verify(targetMemberParty, never()).changeRole(any());
+ verify(notificationCommandService, never()).createNotification(any());
+ }
+
+ @Test
+ @DisplayName("실패 - 대상 멤버가 이미 모임장인 경우 권한을 변경하려 하면 CANNOT_ASSIGN_TO_OWNER 발생")
+ void fail_updateMemberRole_targetIsOwner() {
+ // given
+ Long partyId = 1L;
+ Long ownerId = 1L;
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남");
+ Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, ownerId);
+ ReflectionTestUtils.setField(owner, "id", ownerId);
+
+ Party party = PartyFixture.createParty("모임명", owner.getId(), addr);
+ ReflectionTestUtils.setField(party, "id", partyId);
+
+ // 타겟이 이미 모임장 권한을 가짐
+ MemberParty memberParty = MemberFixture.createMemberParty(party, owner, Role.PARTY_MANAGER);
+ PartyMemberRoleDTO.Request request = new PartyMemberRoleDTO.Request(Role.PARTY_SUBMANAGER);
+
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+ given(memberRepository.findById(ownerId)).willReturn(Optional.of(owner));
+ given(memberPartyRepository.findByPartyAndMember(party, owner)).willReturn(Optional.of(memberParty));
+
+ // when & then
+ PartyException exception = assertThrows(PartyException.class,
+ () -> partyCommandService.updateMemberRole(partyId, ownerId, ownerId, request));
+ assertThat(exception.getCode()).isEqualTo(PartyErrorCode.CANNOT_ASSIGN_TO_OWNER);
+ }
+
+ @Test
+ @DisplayName("실패 - 현재 사용자가 모임장이 아닐 경우 INSUFFICIENT_PERMISSION 발생")
+ void fail_updateMemberRole_notOwner() {
+ // given
+ Long partyId = 1L;
+ Long ownerId = 1L;
+ Long notOwnerId = 2L;
+ Long targetId = 10L;
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남");
+ Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, ownerId);
+ ReflectionTestUtils.setField(owner, "id", ownerId);
+
+ Member notOwner = MemberFixture.createMember("일반멤버", Gender.MALE, Level.A, notOwnerId);
+ ReflectionTestUtils.setField(notOwner, "id", notOwnerId);
+
+ Member targetMember = MemberFixture.createMember("타겟", Gender.MALE, Level.A, targetId);
+ ReflectionTestUtils.setField(targetMember, "id", targetId);
+
+ Party party = PartyFixture.createParty("모임명", owner.getId(), addr);
+ ReflectionTestUtils.setField(party, "id", partyId);
+
+ MemberParty targetMemberParty = MemberFixture.createMemberParty(party, targetMember, Role.PARTY_MEMBER);
+ PartyMemberRoleDTO.Request request = new PartyMemberRoleDTO.Request(Role.PARTY_SUBMANAGER);
+
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+ given(memberRepository.findById(targetId)).willReturn(Optional.of(targetMember));
+ given(memberPartyRepository.findByPartyAndMember(party, targetMember)).willReturn(Optional.of(targetMemberParty));
+
+ // when & then (notOwnerId를 currentMemberId로 전달하여 실행)
+ PartyException exception = assertThrows(PartyException.class,
+ () -> partyCommandService.updateMemberRole(partyId, targetId, notOwnerId, request));
+ assertThat(exception.getCode()).isEqualTo(PartyErrorCode.INSUFFICIENT_PERMISSION);
+ }
+
+ @Test
+ @DisplayName("실패 - 존재하지 않는 파티인 경우 PARTY_NOT_FOUND 예외 발생")
+ void fail_updateMemberRole_partyNotFound() {
+ // given
+ PartyMemberRoleDTO.Request request = new PartyMemberRoleDTO.Request(Role.PARTY_SUBMANAGER);
+ given(partyRepository.findById(999L)).willReturn(Optional.empty());
+
+ // when & then
+ assertThatThrownBy(() -> partyCommandService.updateMemberRole(999L, 1L, 1L, request))
+ .isInstanceOf(PartyException.class)
+ .satisfies(e -> assertThat(((PartyException) e).getCode()).isEqualTo(PartyErrorCode.PARTY_NOT_FOUND));
+ }
+
+ @Test
+ @DisplayName("실패 - 존재하지 않는 회원인 경우 MEMBER_NOT_FOUND 예외 발생")
+ void fail_updateMemberRole_memberNotFound() {
+ // given
+ Long partyId = 1L;
+ Party party = PartyFixture.createParty("모임명", 1L, null);
+ PartyMemberRoleDTO.Request request = new PartyMemberRoleDTO.Request(Role.PARTY_SUBMANAGER);
+
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+ given(memberRepository.findById(1L)).willReturn(Optional.empty());
+
+ // when & then
+ assertThatThrownBy(() -> partyCommandService.updateMemberRole(partyId, 1L, 1L, request))
+ .isInstanceOf(MemberException.class)
+ .satisfies(e -> assertThat(((MemberException) e).getCode()).isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND));
+ }
+ }
+
+ @Nested
+ @DisplayName("removeMember")
+ class RemoveMember {
+
+ @Test
+ @DisplayName("성공 - 모임장이 일반 멤버를 성공적으로 강퇴한다")
+ void success_removeMember() {
+ // given
+ Long partyId = 1L;
+ Long ownerId = 1L;
+ Long targetMemberId = 10L;
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남");
+ Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, ownerId);
+ ReflectionTestUtils.setField(owner, "id", ownerId);
+ Member targetMember = MemberFixture.createMember("타겟", Gender.MALE, Level.A, targetMemberId);
+ ReflectionTestUtils.setField(targetMember, "id", targetMemberId);
+
+ Party party = PartyFixture.createParty("모임명", owner.getId(), addr);
+ ReflectionTestUtils.setField(party, "id", partyId);
+
+ MemberParty ownerParty = MemberFixture.createMemberParty(party, owner, Role.PARTY_MANAGER);
+ MemberParty targetMemberParty = MemberFixture.createMemberParty(party, targetMember, Role.PARTY_MEMBER);
+
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+ given(memberRepository.findById(ownerId)).willReturn(Optional.of(owner));
+ given(memberRepository.findById(targetMemberId)).willReturn(Optional.of(targetMember));
+ given(memberPartyRepository.findByPartyAndMember(party, owner)).willReturn(Optional.of(ownerParty));
+ given(memberPartyRepository.findByPartyAndMember(party, targetMember)).willReturn(Optional.of(targetMemberParty));
+
+ // when
+ partyCommandService.removeMember(partyId, targetMemberId, ownerId);
+
+ // then
+ verify(memberPartyRepository, times(1)).delete(targetMemberParty);
+ verify(chatRoomService, times(1)).leavePartyChatRoom(partyId, targetMemberId);
+ }
+
+ @Test
+ @DisplayName("성공 - 부모임장이 일반 멤버를 성공적으로 강퇴한다")
+ void success_removeMember_bySubManager() {
+ // given
+ Long partyId = 1L;
+ Long subManagerId = 2L;
+ Long targetMemberId = 10L;
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남");
+ Member subManager = MemberFixture.createMember("부모임장", Gender.MALE, Level.A, subManagerId);
+ ReflectionTestUtils.setField(subManager, "id", subManagerId);
+ Member targetMember = MemberFixture.createMember("일반멤버", Gender.MALE, Level.A, targetMemberId);
+ ReflectionTestUtils.setField(targetMember, "id", targetMemberId);
+
+ Party party = PartyFixture.createParty("모임명", 1L, addr); // ownerId = 1L
+ ReflectionTestUtils.setField(party, "id", partyId);
+
+ MemberParty subManagerParty = MemberFixture.createMemberParty(party, subManager, Role.PARTY_SUBMANAGER);
+ MemberParty targetMemberParty = MemberFixture.createMemberParty(party, targetMember, Role.PARTY_MEMBER);
+
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+ given(memberRepository.findById(subManagerId)).willReturn(Optional.of(subManager));
+ given(memberRepository.findById(targetMemberId)).willReturn(Optional.of(targetMember));
+ given(memberPartyRepository.findByPartyAndMember(party, subManager)).willReturn(Optional.of(subManagerParty));
+ given(memberPartyRepository.findByPartyAndMember(party, targetMember)).willReturn(Optional.of(targetMemberParty));
+
+ // when
+ partyCommandService.removeMember(partyId, targetMemberId, subManagerId);
+
+ // then
+ verify(memberPartyRepository, times(1)).delete(targetMemberParty);
+ verify(chatRoomService, times(1)).leavePartyChatRoom(partyId, targetMemberId);
+ }
+
+ @Test
+ @DisplayName("실패 - 권한이 없는 멤버가 타인을 강퇴하려 하면 INSUFFICIENT_PERMISSION 발생")
+ void fail_removeMember_insufficientPermission() {
+ // given
+ Long partyId = 1L;
+ Long subManagerId = 2L;
+ Long targetOwnerId = 1L;
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남");
+ Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, targetOwnerId);
+ ReflectionTestUtils.setField(owner, "id", targetOwnerId);
+ Member subManager = MemberFixture.createMember("부모임장", Gender.MALE, Level.A, subManagerId);
+ ReflectionTestUtils.setField(subManager, "id", subManagerId);
+
+ Party party = PartyFixture.createParty("모임명", owner.getId(), addr);
+ ReflectionTestUtils.setField(party, "id", partyId);
+
+ MemberParty ownerParty = MemberFixture.createMemberParty(party, owner, Role.PARTY_MANAGER);
+ MemberParty subManagerParty = MemberFixture.createMemberParty(party, subManager, Role.PARTY_SUBMANAGER);
+
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+ given(memberRepository.findById(subManagerId)).willReturn(Optional.of(subManager));
+ given(memberRepository.findById(targetOwnerId)).willReturn(Optional.of(owner));
+ given(memberPartyRepository.findByPartyAndMember(party, subManager)).willReturn(Optional.of(subManagerParty));
+ given(memberPartyRepository.findByPartyAndMember(party, owner)).willReturn(Optional.of(ownerParty));
+
+ // when & then
+ PartyException exception = assertThrows(PartyException.class,
+ () -> partyCommandService.removeMember(partyId, targetOwnerId, subManagerId));
+ assertThat(exception.getCode()).isEqualTo(PartyErrorCode.INSUFFICIENT_PERMISSION);
+ }
+
+ @Test
+ @DisplayName("실패 - 모임장이 자신을 강퇴하려 할 경우 CANNOT_REMOVE_SELF 발생")
+ void fail_removeMember_cannotRemoveSelf() {
+ // given
+ Long partyId = 1L;
+ Long ownerId = 1L;
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남");
+ Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, ownerId);
+ ReflectionTestUtils.setField(owner, "id", ownerId);
+
+ Party party = PartyFixture.createParty("모임명", owner.getId(), addr);
+ ReflectionTestUtils.setField(party, "id", partyId);
+
+ MemberParty ownerParty = MemberFixture.createMemberParty(party, owner, Role.PARTY_MANAGER);
+
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+ given(memberRepository.findById(ownerId)).willReturn(Optional.of(owner));
+ given(memberPartyRepository.findByPartyAndMember(party, owner)).willReturn(Optional.of(ownerParty));
+
+ // when & then
+ PartyException exception = assertThrows(PartyException.class,
+ () -> partyCommandService.removeMember(partyId, ownerId, ownerId));
+ assertThat(exception.getCode()).isEqualTo(PartyErrorCode.CANNOT_REMOVE_SELF);
+ }
+
+ @Test
+ @DisplayName("실패 - 대상 멤버가 모임 소속이 아닐 경우 NOT_MEMBER 발생")
+ void fail_removeMember_notMember() {
+ // given
+ Long partyId = 1L;
+ Long ownerId = 1L;
+ Long targetMemberId = 10L;
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남");
+ Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, ownerId);
+ ReflectionTestUtils.setField(owner, "id", ownerId);
+ Member targetMember = MemberFixture.createMember("타겟", Gender.MALE, Level.A, targetMemberId);
+ ReflectionTestUtils.setField(targetMember, "id", targetMemberId);
+
+ Party party = PartyFixture.createParty("모임명", owner.getId(), addr);
+ ReflectionTestUtils.setField(party, "id", partyId);
+
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+ given(memberRepository.findById(ownerId)).willReturn(Optional.of(owner));
+ given(memberRepository.findById(targetMemberId)).willReturn(Optional.of(targetMember));
+
+ // 타겟 멤버가 모임 소속이 아님 -> findMemberPartyOrThrow 에서 NOT_MEMBER 발생
+ given(memberPartyRepository.findByPartyAndMember(party, targetMember)).willReturn(Optional.empty());
+
+ // when & then
+ PartyException exception = assertThrows(PartyException.class,
+ () -> partyCommandService.removeMember(partyId, targetMemberId, ownerId));
+ assertThat(exception.getCode()).isEqualTo(PartyErrorCode.NOT_MEMBER);
+ }
+
+ @Test
+ @DisplayName("실패 - 존재하지 않는 파티인 경우 PARTY_NOT_FOUND 예외 발생")
+ void fail_removeMember_partyNotFound() {
+ // given
+ given(partyRepository.findById(999L)).willReturn(Optional.empty());
+
+ // when & then
+ assertThatThrownBy(() -> partyCommandService.removeMember(999L, 1L, 1L))
+ .isInstanceOf(PartyException.class)
+ .satisfies(e -> assertThat(((PartyException) e).getCode()).isEqualTo(PartyErrorCode.PARTY_NOT_FOUND));
+ }
+
+ @Test
+ @DisplayName("실패 - 존재하지 않는 회원인 경우 MEMBER_NOT_FOUND 예외 발생")
+ void fail_removeMember_memberNotFound() {
+ // given
+ Long partyId = 1L;
+ Party party = PartyFixture.createParty("모임명", 1L, null);
+
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+ given(memberRepository.findById(1L)).willReturn(Optional.empty());
+
+ // when & then
+ assertThatThrownBy(() -> partyCommandService.removeMember(partyId, 10L, 1L))
+ .isInstanceOf(MemberException.class)
+ .satisfies(e -> assertThat(((MemberException) e).getCode()).isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND));
+ }
+ }
+
+ @Nested
+ @DisplayName("actionJoinRequest")
+ class ActionJoinRequest {
+
+ @Test
+ @DisplayName("성공 - 모임장이 가입 신청을 승인하고 알림과 채팅방 진입, 이벤트를 발생시킨다")
+ void success_actionJoinRequest_approve() {
+ // given
+ Long partyId = 1L;
+ Long ownerId = 10L;
+ Long requestId = 100L;
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남");
+ Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, ownerId);
+ ReflectionTestUtils.setField(owner, "id", ownerId);
+ Party party = PartyFixture.createParty("모임명", owner.getId(), addr);
+ ReflectionTestUtils.setField(party, "id", partyId);
+
+ Member applicant = MemberFixture.createMember("지원자", Gender.FEMALE, Level.B, 20L);
+ ReflectionTestUtils.setField(applicant, "id", 20L);
+
+ PartyJoinRequest joinRequest = PartyJoinRequest.builder()
+ .party(party)
+ .member(applicant)
+ .status(RequestStatus.PENDING)
+ .build();
+ ReflectionTestUtils.setField(joinRequest, "id", requestId);
+
+ PartyJoinActionDTO.Request requestDTO = new PartyJoinActionDTO.Request(RequestAction.APPROVE);
+
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+ given(partyJoinRequestRepository.findById(requestId)).willReturn(Optional.of(joinRequest));
+ given(memberPartyRepository.existsByPartyAndMember(party, applicant)).willReturn(false);
+
+ // when
+ partyCommandService.actionJoinRequest(partyId, ownerId, requestDTO, requestId);
+
+ // then
+ assertThat(joinRequest.getStatus()).isEqualTo(RequestStatus.APPROVED);
+ verify(chatRoomService).joinPartyChatRoom(partyId, applicant);
+ verify(applicationEventPublisher).publishEvent(any(PartyMemberJoinedEvent.class));
+ verify(notificationCommandService).createNotification(any());
+ }
+
+ @Test
+ @DisplayName("성공 - 모임장이 가입 신청을 거절하면 상태만 REJECTED로 바뀌고 다른 사이드이펙트가 발생하지 않는다")
+ void success_actionJoinRequest_reject() {
+ // given
+ Long partyId = 1L;
+ Long ownerId = 10L;
+ Long requestId = 100L;
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남");
+ Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, ownerId);
+ ReflectionTestUtils.setField(owner, "id", ownerId);
+ Party party = PartyFixture.createParty("모임명", owner.getId(), addr);
+ ReflectionTestUtils.setField(party, "id", partyId);
+
+ Member applicant = MemberFixture.createMember("지원자", Gender.FEMALE, Level.B, 20L);
+ ReflectionTestUtils.setField(applicant, "id", 20L);
+
+ PartyJoinRequest joinRequest = PartyJoinRequest.builder()
+ .party(party)
+ .member(applicant)
+ .status(RequestStatus.PENDING)
+ .build();
+ ReflectionTestUtils.setField(joinRequest, "id", requestId);
+
+ PartyJoinActionDTO.Request requestDTO = new PartyJoinActionDTO.Request(RequestAction.REJECT);
+
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+ given(partyJoinRequestRepository.findById(requestId)).willReturn(Optional.of(joinRequest));
+ given(memberPartyRepository.existsByPartyAndMember(party, applicant)).willReturn(false);
+
+ // when
+ partyCommandService.actionJoinRequest(partyId, ownerId, requestDTO, requestId);
+
+ // then
+ assertThat(joinRequest.getStatus()).isEqualTo(RequestStatus.REJECTED);
+ verifyNoInteractions(chatRoomService);
+ verifyNoInteractions(applicationEventPublisher);
+ verifyNoInteractions(notificationCommandService);
+ }
+
+ @Test
+ @DisplayName("실패 - 대상자가 이미 모임 멤버인 경우 ALREADY_MEMBER 검증 에러가 발생한다")
+ void fail_actionJoinRequest_alreadyMember() {
+ // given
+ Long partyId = 1L;
+ Long ownerId = 10L;
+ Long requestId = 100L;
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남");
+ Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, ownerId);
+ ReflectionTestUtils.setField(owner, "id", ownerId);
+ Party party = PartyFixture.createParty("모임명", owner.getId(), addr);
+ ReflectionTestUtils.setField(party, "id", partyId);
+
+ Member applicant = MemberFixture.createMember("지원자", Gender.FEMALE, Level.B, 20L);
+ ReflectionTestUtils.setField(applicant, "id", 20L);
+
+ PartyJoinRequest joinRequest = PartyJoinRequest.builder()
+ .party(party)
+ .member(applicant)
+ .status(RequestStatus.PENDING)
+ .build();
+ ReflectionTestUtils.setField(joinRequest, "id", requestId);
+
+ PartyJoinActionDTO.Request requestDTO = new PartyJoinActionDTO.Request(RequestAction.APPROVE);
+
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+ given(partyJoinRequestRepository.findById(requestId)).willReturn(Optional.of(joinRequest));
+ given(memberPartyRepository.existsByPartyAndMember(party, applicant)).willReturn(true); // 이미 멤버
+
+ // when & then
+ PartyException exception = assertThrows(PartyException.class,
+ () -> partyCommandService.actionJoinRequest(partyId, ownerId, requestDTO, requestId));
+ assertThat(exception.getCode()).isEqualTo(PartyErrorCode.ALREADY_MEMBER);
+ }
+
+ @Test
+ @DisplayName("실패 - 이미 처리된 가입 신청을 다시 처리하려 할 때 JOIN_REQUEST_ALREADY_ACTIONS 발생")
+ void fail_actionJoinRequest_alreadyActions() {
+ // given
+ Long partyId = 1L;
+ Long ownerId = 10L;
+ Long requestId = 100L;
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남");
+ Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, ownerId);
+ ReflectionTestUtils.setField(owner, "id", ownerId);
+ Party party = PartyFixture.createParty("모임명", owner.getId(), addr);
+ ReflectionTestUtils.setField(party, "id", partyId);
+
+ Member applicant = MemberFixture.createMember("지원자", Gender.FEMALE, Level.B, 20L);
+ ReflectionTestUtils.setField(applicant, "id", 20L);
+
+ PartyJoinRequest joinRequest = PartyJoinRequest.builder()
+ .party(party)
+ .member(applicant)
+ .status(RequestStatus.APPROVED) // 이미 승인됨
+ .build();
+ ReflectionTestUtils.setField(joinRequest, "id", requestId);
+
+ PartyJoinActionDTO.Request requestDTO = new PartyJoinActionDTO.Request(RequestAction.REJECT);
+
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+ given(partyJoinRequestRepository.findById(requestId)).willReturn(Optional.of(joinRequest));
+ given(memberPartyRepository.existsByPartyAndMember(party, applicant)).willReturn(false);
+
+ // when & then
+ PartyException exception = assertThrows(PartyException.class,
+ () -> partyCommandService.actionJoinRequest(partyId, ownerId, requestDTO, requestId));
+ assertThat(exception.getCode()).isEqualTo(PartyErrorCode.JOIN_REQUEST_ALREADY_ACTIONS);
+ }
+
+ @Test
+ @DisplayName("실패 - 해당 가입 요청을 찾을 수 없는 경우 JOIN_REQUEST_NOT_FOUND 발생")
+ void fail_actionJoinRequest_notFound() {
+ // given
+ Long partyId = 1L;
+ Long ownerId = 10L;
+ Long requestId = 999L; // 존재하지 않는 ID
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남");
+ Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, ownerId);
+ ReflectionTestUtils.setField(owner, "id", ownerId);
+ Party party = PartyFixture.createParty("모임명", owner.getId(), addr);
+ ReflectionTestUtils.setField(party, "id", partyId);
+
+ PartyJoinActionDTO.Request requestDTO = new PartyJoinActionDTO.Request(RequestAction.APPROVE);
+
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+ given(partyJoinRequestRepository.findById(requestId)).willReturn(Optional.empty());
+
+ // when & then
+ PartyException exception = assertThrows(PartyException.class,
+ () -> partyCommandService.actionJoinRequest(partyId, ownerId, requestDTO, requestId));
+ assertThat(exception.getCode()).isEqualTo(PartyErrorCode.JOIN_REQUEST_NOT_FOUND);
+ }
+
+ @Test
+ @DisplayName("실패 - 모임장이 아닌 사용자가 가입 신청을 처리하려 할 때 INSUFFICIENT_PERMISSION 발생")
+ void fail_actionJoinRequest_insufficientPermission() {
+ // given
+ Long partyId = 1L;
+ Long ownerId = 10L;
+ Long notOwnerId = 99L;
+ Long requestId = 100L;
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남");
+ Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, ownerId); // 실제 모임장
+ ReflectionTestUtils.setField(owner, "id", ownerId);
+ Party party = PartyFixture.createParty("모임명", owner.getId(), addr);
+ ReflectionTestUtils.setField(party, "id", partyId);
+
+ Member applicant = MemberFixture.createMember("지원자", Gender.FEMALE, Level.B, 20L);
+ ReflectionTestUtils.setField(applicant, "id", 20L);
+
+ PartyJoinRequest joinRequest = PartyJoinRequest.builder()
+ .party(party)
+ .member(applicant)
+ .status(RequestStatus.PENDING)
+ .build();
+ ReflectionTestUtils.setField(joinRequest, "id", requestId);
+
+ PartyJoinActionDTO.Request requestDTO = new PartyJoinActionDTO.Request(RequestAction.APPROVE);
+
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+ given(partyJoinRequestRepository.findById(requestId)).willReturn(Optional.of(joinRequest));
+
+ // when & then
+ PartyException exception = assertThrows(PartyException.class,
+ () -> partyCommandService.actionJoinRequest(partyId, notOwnerId, requestDTO, requestId));
+ assertThat(exception.getCode()).isEqualTo(PartyErrorCode.INSUFFICIENT_PERMISSION);
+ }
+
+ @Test
+ @DisplayName("실패 - 처리하려는 가입 신청이 해당 모임의 것이 아닌 경우 JOIN_REQUEST_PARTY_NOT_FOUND 발생")
+ void fail_actionJoinRequest_joinRequestPartyNotFound() {
+ // given
+ Long partyId = 1L;
+ Long wrongPartyId = 2L;
+ Long ownerId = 10L;
+ Long requestId = 100L;
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남");
+ Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, ownerId);
+ ReflectionTestUtils.setField(owner, "id", ownerId);
+
+ Party targetParty = PartyFixture.createParty("대상 모임", owner.getId(), addr);
+ ReflectionTestUtils.setField(targetParty, "id", partyId);
+
+ Party wrongParty = PartyFixture.createParty("다른 모임", owner.getId(), addr);
+ ReflectionTestUtils.setField(wrongParty, "id", wrongPartyId);
+
+ Member applicant = MemberFixture.createMember("지원자", Gender.FEMALE, Level.B, 20L);
+ ReflectionTestUtils.setField(applicant, "id", 20L);
+
+ // 다른 모임으로 가입신청
+ PartyJoinRequest joinRequest = PartyJoinRequest.builder()
+ .party(wrongParty)
+ .member(applicant)
+ .status(RequestStatus.PENDING)
+ .build();
+ ReflectionTestUtils.setField(joinRequest, "id", requestId);
+
+ PartyJoinActionDTO.Request requestDTO = new PartyJoinActionDTO.Request(RequestAction.APPROVE);
+
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(targetParty));
+ given(partyJoinRequestRepository.findById(requestId)).willReturn(Optional.of(joinRequest));
+
+ // when & then
+ PartyException exception = assertThrows(PartyException.class,
+ () -> partyCommandService.actionJoinRequest(partyId, ownerId, requestDTO, requestId));
+ assertThat(exception.getCode()).isEqualTo(PartyErrorCode.JOIN_REQUEST_PARTY_NOT_FOUND);
+ }
+
+ @Test
+ @DisplayName("실패 - 존재하지 않는 파티인 경우 PARTY_NOT_FOUND 예외 발생")
+ void fail_actionJoinRequest_partyNotFound() {
+ // given
+ given(partyRepository.findById(999L)).willReturn(Optional.empty());
+
+ // when & then
+ assertThatThrownBy(() -> partyCommandService.actionJoinRequest(999L, 1L, new PartyJoinActionDTO.Request(RequestAction.APPROVE), 1L))
+ .isInstanceOf(PartyException.class)
+ .satisfies(e -> assertThat(((PartyException) e).getCode()).isEqualTo(PartyErrorCode.PARTY_NOT_FOUND));
+ }
+ }
+
+ @Nested
+ @DisplayName("createInvitation")
+ class CreateInvitation {
+
+ @Test
+ @DisplayName("성공 - 모임장이 새로운 멤버를 초대하고 invitationId를 반환한다")
+ void success_createInvitation() {
+ // given
+ Long partyId = 1L;
+ Long ownerId = 10L;
+ Long inviteeId = 20L;
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남");
+ Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, ownerId);
+ ReflectionTestUtils.setField(owner, "id", ownerId);
+ Member invitee = MemberFixture.createMember("초대대상", Gender.FEMALE, Level.B, inviteeId);
+ ReflectionTestUtils.setField(invitee, "id", inviteeId);
+ Party party = PartyFixture.createParty("모임명", ownerId, addr);
+ ReflectionTestUtils.setField(party, "id", partyId);
+
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+ given(memberRepository.findById(ownerId)).willReturn(Optional.of(owner));
+ given(memberRepository.findById(inviteeId)).willReturn(Optional.of(invitee));
+ given(memberPartyRepository.existsByPartyAndMember(party, invitee)).willReturn(false);
+ given(partyInvitationRepository.existsByPartyAndInviteeAndStatus(party, invitee, RequestStatus.PENDING)).willReturn(false);
+
+ PartyInvitation savedInvitation = PartyInvitation.create(party, owner, invitee);
+ ReflectionTestUtils.setField(savedInvitation, "id", 100L);
+ given(partyInvitationRepository.save(any())).willReturn(savedInvitation);
+
+ // when
+ PartyInviteCreateDTO.Response response = partyCommandService.createInvitation(partyId, inviteeId, ownerId);
+
+ // then
+ assertThat(response.invitationId()).isEqualTo(100L);
+ verify(notificationCommandService).createNotification(any());
+ }
+
+ @Test
+ @DisplayName("실패 - 모임장이 아닌 사용자가 초대하려 하면 INSUFFICIENT_PERMISSION 발생")
+ void fail_createInvitation_notOwner() {
+ // given
+ Long partyId = 1L;
+ Long nonOwnerId = 99L;
+ Long inviteeId = 20L;
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남");
+ Member nonOwner = MemberFixture.createMember("일반멤버", Gender.MALE, Level.B, nonOwnerId);
+ ReflectionTestUtils.setField(nonOwner, "id", nonOwnerId);
+ Member invitee = MemberFixture.createMember("초대대상", Gender.FEMALE, Level.B, inviteeId);
+ ReflectionTestUtils.setField(invitee, "id", inviteeId);
+ Party party = PartyFixture.createParty("모임명", 10L, addr); // ownerId = 10L
+ ReflectionTestUtils.setField(party, "id", partyId);
+
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+ given(memberRepository.findById(nonOwnerId)).willReturn(Optional.of(nonOwner));
+ given(memberRepository.findById(inviteeId)).willReturn(Optional.of(invitee));
+
+ // when & then
+ PartyException exception = assertThrows(PartyException.class,
+ () -> partyCommandService.createInvitation(partyId, inviteeId, nonOwnerId));
+ assertThat(exception.getCode()).isEqualTo(PartyErrorCode.INSUFFICIENT_PERMISSION);
+ }
+
+ @Test
+ @DisplayName("실패 - 이미 모임에 가입한 멤버를 초대하면 ALREADY_MEMBER 발생")
+ void fail_createInvitation_alreadyMember() {
+ // given
+ Long partyId = 1L;
+ Long ownerId = 10L;
+ Long inviteeId = 20L;
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남");
+ Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, ownerId);
+ ReflectionTestUtils.setField(owner, "id", ownerId);
+ Member invitee = MemberFixture.createMember("대상멤버", Gender.FEMALE, Level.B, inviteeId);
+ ReflectionTestUtils.setField(invitee, "id", inviteeId);
+ Party party = PartyFixture.createParty("모임명", ownerId, addr);
+ ReflectionTestUtils.setField(party, "id", partyId);
+
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+ given(memberRepository.findById(ownerId)).willReturn(Optional.of(owner));
+ given(memberRepository.findById(inviteeId)).willReturn(Optional.of(invitee));
+ given(memberPartyRepository.existsByPartyAndMember(party, invitee)).willReturn(true); // 이미 멤버
+
+ // when & then
+ PartyException exception = assertThrows(PartyException.class,
+ () -> partyCommandService.createInvitation(partyId, inviteeId, ownerId));
+ assertThat(exception.getCode()).isEqualTo(PartyErrorCode.ALREADY_MEMBER);
+ }
+
+ @Test
+ @DisplayName("실패 - 이미 대기 중인 초대가 있는 멤버를 중복 초대하면 INVITATION_ALREADY_EXISTS 발생")
+ void fail_createInvitation_duplicateInvitation() {
+ // given
+ Long partyId = 1L;
+ Long ownerId = 10L;
+ Long inviteeId = 20L;
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남");
+ Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, ownerId);
+ ReflectionTestUtils.setField(owner, "id", ownerId);
+ Member invitee = MemberFixture.createMember("대상멤버", Gender.FEMALE, Level.B, inviteeId);
+ ReflectionTestUtils.setField(invitee, "id", inviteeId);
+ Party party = PartyFixture.createParty("모임명", ownerId, addr);
+ ReflectionTestUtils.setField(party, "id", partyId);
+
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+ given(memberRepository.findById(ownerId)).willReturn(Optional.of(owner));
+ given(memberRepository.findById(inviteeId)).willReturn(Optional.of(invitee));
+ given(memberPartyRepository.existsByPartyAndMember(party, invitee)).willReturn(false);
+ given(partyInvitationRepository.existsByPartyAndInviteeAndStatus(party, invitee, RequestStatus.PENDING)).willReturn(true); // 이미 대기중 초대
+
+ // when & then
+ PartyException exception = assertThrows(PartyException.class,
+ () -> partyCommandService.createInvitation(partyId, inviteeId, ownerId));
+ assertThat(exception.getCode()).isEqualTo(PartyErrorCode.INVITATION_ALREADY_EXISTS);
+ }
+
+ @Test
+ @DisplayName("실패 - 존재하지 않는 파티인 경우 PARTY_NOT_FOUND 예외 발생")
+ void fail_createInvitation_partyNotFound() {
+ // given
+ given(partyRepository.findById(999L)).willReturn(Optional.empty());
+
+ // when & then
+ assertThatThrownBy(() -> partyCommandService.createInvitation(999L, 1L, 1L))
+ .isInstanceOf(PartyException.class)
+ .satisfies(e -> assertThat(((PartyException) e).getCode()).isEqualTo(PartyErrorCode.PARTY_NOT_FOUND));
+ }
+
+ @Test
+ @DisplayName("실패 - 존재하지 않는 회원인 경우 MEMBER_NOT_FOUND 예외 발생")
+ void fail_createInvitation_memberNotFound() {
+ // given
+ Long partyId = 1L;
+ Party party = PartyFixture.createParty("모임명", 1L, null);
+
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+ given(memberRepository.findById(1L)).willReturn(Optional.empty());
+
+ // when & then
+ assertThatThrownBy(() -> partyCommandService.createInvitation(partyId, 1L, 1L))
+ .isInstanceOf(MemberException.class)
+ .satisfies(e -> assertThat(((MemberException) e).getCode()).isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND));
+ }
+ }
+
+ @Nested
+ @DisplayName("actionInvitation")
+ class ActionInvitation {
+
+ @Test
+ @DisplayName("성공 - 초대받은 멤버가 승인하면 모임 멤버로 추가되고 알림이 발생한다")
+ void success_actionInvitation_approve() {
+ // given
+ Long invitationId = 100L;
+ Long ownerId = 10L;
+ Long inviteeId = 20L;
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남");
+ Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, ownerId);
+ ReflectionTestUtils.setField(owner, "id", ownerId);
+ Member invitee = MemberFixture.createMember("초대대상", Gender.FEMALE, Level.B, inviteeId);
+ ReflectionTestUtils.setField(invitee, "id", inviteeId);
+ Party party = PartyFixture.createParty("모임명", ownerId, addr);
+ ReflectionTestUtils.setField(party, "id", 1L);
+
+ PartyInvitation invitation = PartyInvitation.create(party, owner, invitee);
+ ReflectionTestUtils.setField(invitation, "id", invitationId);
+
+ PartyInviteActionDTO.Request request = new PartyInviteActionDTO.Request(RequestAction.APPROVE);
+
+ given(partyInvitationRepository.findById(invitationId)).willReturn(Optional.of(invitation));
+ given(memberRepository.findById(inviteeId)).willReturn(Optional.of(invitee));
+ given(memberPartyRepository.existsByPartyAndMember(party, invitee)).willReturn(false);
+
+ // when
+ partyCommandService.actionInvitation(inviteeId, request, invitationId);
+
+ // then
+ assertThat(invitation.getStatus()).isEqualTo(RequestStatus.APPROVED);
+ verify(chatRoomService).joinPartyChatRoom(party.getId(), invitee);
+ verify(applicationEventPublisher).publishEvent(any(PartyMemberJoinedEvent.class));
+ verify(notificationCommandService).createNotification(any());
+ }
+
+ @Test
+ @DisplayName("성공 - 초대받은 멤버가 거절하면 상태만 REJECTED로 바뀌고 사이드이펙트가 없다")
+ void success_actionInvitation_reject() {
+ // given
+ Long invitationId = 100L;
+ Long ownerId = 10L;
+ Long inviteeId = 20L;
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남");
+ Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, ownerId);
+ ReflectionTestUtils.setField(owner, "id", ownerId);
+ Member invitee = MemberFixture.createMember("초대대상", Gender.FEMALE, Level.B, inviteeId);
+ ReflectionTestUtils.setField(invitee, "id", inviteeId);
+ Party party = PartyFixture.createParty("모임명", ownerId, addr);
+ ReflectionTestUtils.setField(party, "id", 1L);
+
+ PartyInvitation invitation = PartyInvitation.create(party, owner, invitee);
+ ReflectionTestUtils.setField(invitation, "id", invitationId);
+
+ PartyInviteActionDTO.Request request = new PartyInviteActionDTO.Request(RequestAction.REJECT);
+
+ given(partyInvitationRepository.findById(invitationId)).willReturn(Optional.of(invitation));
+ given(memberRepository.findById(inviteeId)).willReturn(Optional.of(invitee));
+ given(memberPartyRepository.existsByPartyAndMember(party, invitee)).willReturn(false);
+
+ // when
+ partyCommandService.actionInvitation(inviteeId, request, invitationId);
+
+ // then
+ assertThat(invitation.getStatus()).isEqualTo(RequestStatus.REJECTED);
+ verifyNoInteractions(chatRoomService);
+ verifyNoInteractions(applicationEventPublisher);
+ verifyNoInteractions(notificationCommandService);
+ }
+
+ @Test
+ @DisplayName("실패 - 초대받은 사람이 아닌 제3자가 처리하려 하면 NOT_YOUR_INVITATION 발생")
+ void fail_actionInvitation_notYourInvitation() {
+ // given
+ Long invitationId = 100L;
+ Long ownerId = 10L;
+ Long inviteeId = 20L;
+ Long otherId = 99L;
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남");
+ Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, ownerId);
+ ReflectionTestUtils.setField(owner, "id", ownerId);
+ Member invitee = MemberFixture.createMember("초대대상", Gender.FEMALE, Level.B, inviteeId);
+ ReflectionTestUtils.setField(invitee, "id", inviteeId);
+ Member other = MemberFixture.createMember("제3자", Gender.MALE, Level.C, otherId);
+ ReflectionTestUtils.setField(other, "id", otherId);
+ Party party = PartyFixture.createParty("모임명", ownerId, addr);
+ ReflectionTestUtils.setField(party, "id", 1L);
+
+ PartyInvitation invitation = PartyInvitation.create(party, owner, invitee);
+ ReflectionTestUtils.setField(invitation, "id", invitationId);
+
+ PartyInviteActionDTO.Request request = new PartyInviteActionDTO.Request(RequestAction.APPROVE);
+
+ given(partyInvitationRepository.findById(invitationId)).willReturn(Optional.of(invitation));
+ given(memberRepository.findById(otherId)).willReturn(Optional.of(other));
+
+ // when & then
+ PartyException exception = assertThrows(PartyException.class,
+ () -> partyCommandService.actionInvitation(otherId, request, invitationId));
+ assertThat(exception.getCode()).isEqualTo(PartyErrorCode.NOT_YOUR_INVITATION);
+ }
+
+ @Test
+ @DisplayName("실패 - 이미 처리된 초대를 다시 처리하려 할 때 INVITATION_ALREADY_ACTIONS 발생")
+ void fail_actionInvitation_alreadyActions() {
+ // given
+ Long invitationId = 100L;
+ Long ownerId = 10L;
+ Long inviteeId = 20L;
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남");
+ Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, ownerId);
+ ReflectionTestUtils.setField(owner, "id", ownerId);
+ Member invitee = MemberFixture.createMember("초대대상", Gender.FEMALE, Level.B, inviteeId);
+ ReflectionTestUtils.setField(invitee, "id", inviteeId);
+ Party party = PartyFixture.createParty("모임명", ownerId, addr);
+ ReflectionTestUtils.setField(party, "id", 1L);
+
+ // 이미 승인된 초대
+ PartyInvitation invitation = PartyInvitation.create(party, owner, invitee);
+ ReflectionTestUtils.setField(invitation, "id", invitationId);
+ invitation.updateStatus(RequestStatus.APPROVED);
+
+ PartyInviteActionDTO.Request request = new PartyInviteActionDTO.Request(RequestAction.REJECT);
+
+ given(partyInvitationRepository.findById(invitationId)).willReturn(Optional.of(invitation));
+ given(memberRepository.findById(inviteeId)).willReturn(Optional.of(invitee));
+ given(memberPartyRepository.existsByPartyAndMember(party, invitee)).willReturn(false);
+
+ // when & then
+ PartyException exception = assertThrows(PartyException.class,
+ () -> partyCommandService.actionInvitation(inviteeId, request, invitationId));
+ assertThat(exception.getCode()).isEqualTo(PartyErrorCode.INVITATION_ALREADY_ACTIONS);
+ }
+
+ @Test
+ @DisplayName("실패 - 존재하지 않는 회원인 경우 MEMBER_NOT_FOUND 예외 발생")
+ void fail_actionInvitation_memberNotFound() {
+ // given
+ Long invitationId = 100L;
+ Party party = PartyFixture.createParty("모임명", 1L, null);
+ Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, 1L);
+ Member invitee = MemberFixture.createMember("초대대상", Gender.FEMALE, Level.B, 20L);
+ PartyInvitation invitation = PartyInvitation.create(party, owner, invitee);
+
+ PartyInviteActionDTO.Request request = new PartyInviteActionDTO.Request(RequestAction.APPROVE);
+ given(partyInvitationRepository.findById(invitationId)).willReturn(Optional.of(invitation));
+ given(memberRepository.findById(20L)).willReturn(Optional.empty());
+
+ // when & then
+ assertThatThrownBy(() -> partyCommandService.actionInvitation(20L, request, invitationId))
+ .isInstanceOf(MemberException.class)
+ .satisfies(e -> assertThat(((MemberException) e).getCode()).isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND));
+ }
+ }
+
+ @Nested
+ @DisplayName("addKeyword")
+ class AddKeyword {
+
+ @Test
+ @DisplayName("성공 - 모임장이 유효한 키워드 목록을 모임에 추가한다")
+ void success_addKeyword() {
+ // given
+ Long partyId = 1L;
+ Long ownerId = 10L;
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남");
+ Party party = PartyFixture.createParty("모임명", ownerId, addr);
+ ReflectionTestUtils.setField(party, "id", partyId);
+
+ PartyKeywordDTO.Request request = new PartyKeywordDTO.Request(
+ List.of("친목", "가입비 무료")
+ );
+
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+
+ // when
+ partyCommandService.addKeyword(partyId, ownerId, request);
+
+ // then
+ assertThat(party.getKeywords()).hasSize(2);
+ }
+
+ @Test
+ @DisplayName("실패 - 모임장이 아닌 사용자가 키워드를 추가하면 INSUFFICIENT_PERMISSION 발생")
+ void fail_addKeyword_notOwner() {
+ // given
+ Long partyId = 1L;
+ Long ownerId = 10L;
+ Long nonOwnerId = 99L;
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남");
+ Party party = PartyFixture.createParty("모임명", ownerId, addr);
+ ReflectionTestUtils.setField(party, "id", partyId);
+
+ PartyKeywordDTO.Request request = new PartyKeywordDTO.Request(List.of("친목"));
+
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+
+ // when & then
+ PartyException exception = assertThrows(PartyException.class,
+ () -> partyCommandService.addKeyword(partyId, nonOwnerId, request));
+ assertThat(exception.getCode()).isEqualTo(PartyErrorCode.INSUFFICIENT_PERMISSION);
+ }
+
+ @Test
+ @DisplayName("실패 - 유효하지 않은 키워드 문자열을 전달하면 INVALID_KEYWORD 발생")
+ void fail_addKeyword_invalidKeyword() {
+ // given
+ Long partyId = 1L;
+ Long ownerId = 10L;
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남");
+ Party party = PartyFixture.createParty("모임명", ownerId, addr);
+ ReflectionTestUtils.setField(party, "id", partyId);
+
+ PartyKeywordDTO.Request request = new PartyKeywordDTO.Request(List.of("존재하지않는키워드"));
+
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+
+ // when & then
+ PartyException exception = assertThrows(PartyException.class,
+ () -> partyCommandService.addKeyword(partyId, ownerId, request));
+ assertThat(exception.getCode()).isEqualTo(PartyErrorCode.INVALID_KEYWORD);
+ }
+
+ @Test
+ @DisplayName("실패 - 존재하지 않는 파티인 경우 PARTY_NOT_FOUND 예외 발생")
+ void fail_addKeyword_partyNotFound() {
+ // given
+ PartyKeywordDTO.Request request = new PartyKeywordDTO.Request(List.of("새키워드"));
+ given(partyRepository.findById(999L)).willReturn(Optional.empty());
+
+ // when & then
+ assertThatThrownBy(() -> partyCommandService.addKeyword(999L, 10L, request))
+ .isInstanceOf(PartyException.class)
+ .satisfies(e -> assertThat(((PartyException) e).getCode()).isEqualTo(PartyErrorCode.PARTY_NOT_FOUND));
+ }
+ }
+}
diff --git a/src/test/java/umc/cockple/demo/domain/party/service/PartyQueryServiceTest.java b/src/test/java/umc/cockple/demo/domain/party/service/PartyQueryServiceTest.java
index 5f6a9005e..e9427a595 100644
--- a/src/test/java/umc/cockple/demo/domain/party/service/PartyQueryServiceTest.java
+++ b/src/test/java/umc/cockple/demo/domain/party/service/PartyQueryServiceTest.java
@@ -1,5 +1,6 @@
package umc.cockple.demo.domain.party.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;
@@ -7,17 +8,32 @@
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.test.util.ReflectionTestUtils;
+import umc.cockple.demo.domain.bookmark.repository.PartyBookmarkRepository;
+import umc.cockple.demo.domain.exercise.repository.ExerciseRepository;
+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.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.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.party.converter.PartyConverter;
import umc.cockple.demo.domain.party.domain.Party;
import umc.cockple.demo.domain.party.domain.PartyAddr;
-import umc.cockple.demo.domain.party.dto.PartyMemberDTO;
+import umc.cockple.demo.domain.party.domain.PartyJoinRequest;
+import umc.cockple.demo.domain.party.dto.*;
+import umc.cockple.demo.domain.party.enums.RequestStatus;
import umc.cockple.demo.domain.party.exception.PartyErrorCode;
import umc.cockple.demo.domain.party.exception.PartyException;
+import umc.cockple.demo.domain.party.repository.PartyJoinRequestRepository;
import umc.cockple.demo.domain.party.repository.PartyRepository;
import umc.cockple.demo.global.enums.Gender;
import umc.cockple.demo.global.enums.Level;
@@ -27,13 +43,13 @@
import java.time.LocalDate;
import java.util.List;
-import java.util.Map;
import java.util.Optional;
+import java.util.Set;
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.eq;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.*;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;
@@ -46,19 +62,78 @@ class PartyQueryServiceTest {
@Mock
private PartyRepository partyRepository;
@Mock
+ private MemberRepository memberRepository;
+
private PartyConverter partyConverter;
+
@Mock
private MemberPartyRepository memberPartyRepository;
@Mock
private MemberExerciseRepository memberExerciseRepository;
+ @Mock
+ private ExerciseRepository exerciseRepository;
+ @Mock
+ private PartyBookmarkRepository partyBookmarkRepository;
+ @Mock
+ private MemberAddrRepository memberAddrRepository;
+ @Mock
+ private FileService fileService;
+ @Mock
+ private PartyJoinRequestRepository partyJoinRequestRepository;
+
+ @BeforeEach
+ void setUp() {
+ partyConverter = new PartyConverter(fileService);
+ ReflectionTestUtils.setField(partyQueryService, "partyConverter", partyConverter);
+ }
@Nested
@DisplayName("getPartyMembers")
class GetPartyMembers {
@Test
- @DisplayName("멤버 목록과 마지막 운동일을 함께 반환한다")
- void success() {
+ @DisplayName("성공 - 모임의 멤버들을 역할별로 성공적으로 조회한다.")
+ void success_getPartyMembers() {
+ // given
+ Long partyId = 1L;
+ Long currentMemberId = 10L;
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울특별시", "강남구");
+ Party party = PartyFixture.createParty("테스트 모임", 10L, addr);
+ ReflectionTestUtils.setField(party, "id", partyId);
+
+ Member manager = MemberFixture.createMember("모임장", Gender.MALE, Level.A, 1001L);
+ Member subManager = MemberFixture.createMember("부모임장", Gender.FEMALE, Level.B, 1002L);
+ Member normalMember = MemberFixture.createMember("일반멤버", Gender.MALE, Level.C, 1003L);
+
+ ReflectionTestUtils.setField(manager, "id", 10L);
+ ReflectionTestUtils.setField(subManager, "id", 20L);
+ ReflectionTestUtils.setField(normalMember, "id", 30L);
+
+ MemberParty mp1 = MemberFixture.createMemberParty(party, manager, Role.PARTY_MANAGER);
+ MemberParty mp2 = MemberFixture.createMemberParty(party, subManager, Role.PARTY_SUBMANAGER);
+ MemberParty mp3 = MemberFixture.createMemberParty(party, normalMember, Role.PARTY_MEMBER);
+ List memberParties = List.of(mp1, mp2, mp3);
+
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+ given(memberPartyRepository.findAllByPartyIdWithMember(partyId)).willReturn(memberParties);
+ given(memberExerciseRepository.findLastExerciseDateByMemberIdsAndPartyId(anyList(),
+ eq(partyId)))
+ .willReturn(List.of());
+
+ // when
+ PartyMemberDTO.Response result = partyQueryService.getPartyMembers(partyId, currentMemberId);
+
+ // then
+ assertThat(result.members()).hasSize(3);
+ assertThat(result.summary().totalCount()).isEqualTo(3);
+ assertThat(result.summary().maleCount()).isEqualTo(2);
+ assertThat(result.summary().femaleCount()).isEqualTo(1);
+ }
+
+ @Test
+ @DisplayName("성공 - 멤버 목록과 마지막 운동일을 함께 반환한다")
+ void success_getPartyMembers_withExerciseHistory() {
// given
Long partyId = 1L;
Long currentMemberId = 10L;
@@ -71,8 +146,8 @@ void success() {
ReflectionTestUtils.setField(manager, "id", 10L);
ReflectionTestUtils.setField(member1, "id", 20L);
- MemberParty mp1 = MemberFixture.createMemberParty(party, manager, Role.party_MANAGER);
- MemberParty mp2 = MemberFixture.createMemberParty(party, member1, Role.party_MEMBER);
+ MemberParty mp1 = MemberFixture.createMemberParty(party, manager, Role.PARTY_MANAGER);
+ MemberParty mp2 = MemberFixture.createMemberParty(party, member1, Role.PARTY_MEMBER);
List memberParties = List.of(mp1, mp2);
LocalDate lastDate = LocalDate.of(2025, 1, 10);
@@ -88,26 +163,23 @@ void success() {
given(memberPartyRepository.findAllByPartyIdWithMember(partyId)).willReturn(memberParties);
given(memberExerciseRepository.findLastExerciseDateByMemberIdsAndPartyId(
List.of(10L, 20L), partyId)).willReturn(rawResult);
- given(partyConverter.toPartyMemberDTO(eq(memberParties), eq(currentMemberId), any()))
- .willReturn(expected);
// when
PartyMemberDTO.Response result = partyQueryService.getPartyMembers(partyId, currentMemberId);
// then
- assertThat(result).isEqualTo(expected);
- verify(memberExerciseRepository).findLastExerciseDateByMemberIdsAndPartyId(
- List.of(10L, 20L), partyId);
- verify(partyConverter).toPartyMemberDTO(
- eq(memberParties),
- eq(currentMemberId),
- eq(Map.of(20L, lastDate))
- );
+ assertThat(result.summary().totalCount()).isEqualTo(2);
+ assertThat(result.members()).hasSize(2);
+ // 마지막 운동일 확인 (멤버1 id: 20L)
+ assertThat(result.members().stream()
+ .filter(m -> m.memberId().equals(20L))
+ .findFirst()
+ .get().lastExerciseDate()).isEqualTo(lastDate);
}
@Test
- @DisplayName("운동 기록이 없는 멤버는 빈 Map이 converter에 전달된다")
- void noExerciseHistory() {
+ @DisplayName("성공 - 운동 기록이 없는 멤버는 빈 Map이 converter에 전달된다")
+ void success_getPartyMembers_noExerciseHistory() {
// given
Long partyId = 1L;
Long currentMemberId = 10L;
@@ -117,41 +189,38 @@ void noExerciseHistory() {
ReflectionTestUtils.setField(party, "id", partyId);
Member manager = MemberFixture.createMember("매니저", Gender.MALE, Level.A, 1001L);
ReflectionTestUtils.setField(manager, "id", 10L);
- MemberParty mp = MemberFixture.createMemberParty(party, manager, Role.party_MANAGER);
+ MemberParty mp = MemberFixture.createMemberParty(party, manager, Role.PARTY_MANAGER);
List memberParties = List.of(mp);
given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
given(memberPartyRepository.findAllByPartyIdWithMember(partyId)).willReturn(memberParties);
given(memberExerciseRepository.findLastExerciseDateByMemberIdsAndPartyId(
List.of(10L), partyId)).willReturn(List.of());
- given(partyConverter.toPartyMemberDTO(any(), any(), any())).willReturn(null);
// when
- partyQueryService.getPartyMembers(partyId, currentMemberId);
+ PartyMemberDTO.Response result = partyQueryService.getPartyMembers(partyId, currentMemberId);
// then
- verify(partyConverter).toPartyMemberDTO(
- eq(memberParties),
- eq(currentMemberId),
- eq(Map.of())
- );
+ assertThat(result.members()).hasSize(1);
+ assertThat(result.members().get(0).lastExerciseDate()).isNull();
}
@Test
- @DisplayName("존재하지 않는 파티면 PartyException을 던진다")
- void partyNotFound() {
+ @DisplayName("실패 - 존재하지 않는 파티면 PartyException을 던진다")
+ void fail_getPartyMembers_partyNotFound() {
// given
given(partyRepository.findById(99L)).willReturn(Optional.empty());
// when & then
assertThatThrownBy(() -> partyQueryService.getPartyMembers(99L, 1L))
.isInstanceOf(PartyException.class)
- .satisfies(e -> assertThat(((PartyException) e).getCode()).isEqualTo(PartyErrorCode.PARTY_NOT_FOUND));
+ .satisfies(e -> assertThat(((PartyException) e).getCode())
+ .isEqualTo(PartyErrorCode.PARTY_NOT_FOUND));
}
@Test
- @DisplayName("비활성화된 파티면 PartyException을 던진다")
- void partyInactive() {
+ @DisplayName("실패 - 비활성화된 파티면 PartyException을 던진다")
+ void fail_getPartyMembers_partyInactive() {
// given
PartyAddr addr = PartyFixture.createPartyAddr("서울특별시", "강남구");
Party inactiveParty = PartyFixture.createParty("테스트 모임", 10L, addr);
@@ -163,8 +232,763 @@ void partyInactive() {
// when & then
assertThatThrownBy(() -> partyQueryService.getPartyMembers(1L, 1L))
.isInstanceOf(PartyException.class)
- .satisfies(e -> assertThat(((PartyException) e).getCode()).isEqualTo(PartyErrorCode.PARTY_IS_DELETED));
+ .satisfies(e -> assertThat(((PartyException) e).getCode())
+ .isEqualTo(PartyErrorCode.PARTY_IS_DELETED));
+ }
+ }
+
+ @Nested
+ @DisplayName("getMyParties")
+ class GetMyParties {
+
+ @Test
+ @DisplayName("성공 - 내 모임 목록을 최신순(기본값)으로 페이징하여 반환한다")
+ void success_getMyParties() {
+ // given
+ Long memberId = 10L;
+ Pageable pageable = PageRequest.of(0, 10);
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울특별시", "강남구");
+ Party party1 = PartyFixture.createParty("모임1", 10L, addr);
+ ReflectionTestUtils.setField(party1, "id", 1L);
+ Party party2 = PartyFixture.createParty("모임2", 10L, addr);
+ ReflectionTestUtils.setField(party2, "id", 2L);
+ Party party3 = PartyFixture.createParty("모임3", 10L, addr);
+ ReflectionTestUtils.setField(party3, "id", 3L);
+
+ Slice partySlice = new SliceImpl<>(List.of(party3, party2, party1), pageable, false);
+
+ given(partyRepository.findMyParty(eq(memberId), eq(false), any(Pageable.class)))
+ .willReturn(partySlice);
+ given(exerciseRepository.findTotalExerciseCountsByPartyIds(anyList()))
+ .willReturn(List.of());
+ given(exerciseRepository.findUpcomingExercisesByPartyIds(anyList()))
+ .willReturn(List.of());
+ given(partyBookmarkRepository.findAllPartyIdsByMemberId(memberId))
+ .willReturn(Set.of(1L, 2L, 3L));
+
+ // when
+ Slice result = partyQueryService.getMyParties(memberId, false, "최신순", pageable);
+
+ // then
+ assertThat(result.getContent()).hasSize(3);
+ assertThat(result.getContent().get(0).partyId()).isEqualTo(3L);
+ assertThat(result.getContent().get(1).partyId()).isEqualTo(2L);
+ assertThat(result.getContent().get(2).partyId()).isEqualTo(1L);
+
+ org.mockito.ArgumentCaptor captor = org.mockito.ArgumentCaptor.forClass(Pageable.class);
+ verify(partyRepository).findMyParty(eq(memberId), eq(false), captor.capture());
+ assertThat(captor.getValue().getSort().getOrderFor("createdAt").getDirection())
+ .isEqualTo(org.springframework.data.domain.Sort.Direction.DESC);
+ }
+
+ @Test
+ @DisplayName("성공 - 내 모임 목록을 오래된 순으로 페이징하여 반환한다")
+ void success_getMyParties_oldest() {
+ // given
+ Long memberId = 10L;
+ Pageable pageable = PageRequest.of(0, 10);
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울특별시", "강남구");
+
+ Party party1 = PartyFixture.createParty("모임1", 10L, addr);
+ ReflectionTestUtils.setField(party1, "id", 1L);
+ Party party2 = PartyFixture.createParty("모임2", 10L, addr);
+ ReflectionTestUtils.setField(party2, "id", 2L);
+ Party party3 = PartyFixture.createParty("모임3", 10L, addr);
+ ReflectionTestUtils.setField(party3, "id", 3L);
+
+ // 오래된 순 응답 가정
+ Slice partySlice = new SliceImpl<>(List.of(party1, party2, party3), pageable, false);
+
+ given(partyRepository.findMyParty(eq(memberId), eq(false), any(Pageable.class)))
+ .willReturn(partySlice);
+ given(exerciseRepository.findTotalExerciseCountsByPartyIds(anyList()))
+ .willReturn(List.of());
+ given(exerciseRepository.findUpcomingExercisesByPartyIds(anyList()))
+ .willReturn(List.of());
+ given(partyBookmarkRepository.findAllPartyIdsByMemberId(memberId))
+ .willReturn(Set.of());
+
+ // when
+ Slice result = partyQueryService.getMyParties(memberId, false, "오래된 순", pageable);
+
+ // then
+ assertThat(result.getContent()).hasSize(3);
+ assertThat(result.getContent().get(0).partyId()).isEqualTo(1L);
+
+ org.mockito.ArgumentCaptor captor = org.mockito.ArgumentCaptor.forClass(Pageable.class);
+ verify(partyRepository).findMyParty(eq(memberId), eq(false), captor.capture());
+ assertThat(captor.getValue().getSort().getOrderFor("createdAt").getDirection())
+ .isEqualTo(org.springframework.data.domain.Sort.Direction.ASC);
+ }
+
+ @Test
+ @DisplayName("성공 - 내 모임 목록을 운동 많은 순으로 페이징하여 반환한다")
+ void success_getMyParties_exerciseCount() {
+ // given
+ Long memberId = 10L;
+ Pageable pageable = PageRequest.of(0, 10);
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울특별시", "강남구");
+
+ Party party1 = PartyFixture.createParty("모임1", 10L, addr);
+ ReflectionTestUtils.setField(party1, "id", 1L);
+ Party party2 = PartyFixture.createParty("모임2", 10L, addr);
+ ReflectionTestUtils.setField(party2, "id", 2L);
+ Party party3 = PartyFixture.createParty("모임3", 10L, addr);
+ ReflectionTestUtils.setField(party3, "id", 3L);
+
+ // 운동 많은 순 응답 가정 (20회, 10회, 5회)
+ Slice partySlice = new SliceImpl<>(List.of(party2, party1, party3), pageable, false);
+
+ given(partyRepository.findMyParty(eq(memberId), eq(false), any(Pageable.class)))
+ .willReturn(partySlice);
+ given(exerciseRepository.findTotalExerciseCountsByPartyIds(anyList()))
+ .willReturn(List.of());
+ given(exerciseRepository.findUpcomingExercisesByPartyIds(anyList()))
+ .willReturn(List.of());
+ given(partyBookmarkRepository.findAllPartyIdsByMemberId(memberId))
+ .willReturn(Set.of());
+
+ // when
+ Slice result = partyQueryService.getMyParties(memberId, false, "운동 많은 순", pageable);
+
+ // then
+ assertThat(result.getContent()).hasSize(3);
+ assertThat(result.getContent().get(0).partyId()).isEqualTo(2L);
+
+ org.mockito.ArgumentCaptor captor = org.mockito.ArgumentCaptor.forClass(Pageable.class);
+ verify(partyRepository).findMyParty(eq(memberId), eq(false), captor.capture());
+ assertThat(captor.getValue().getSort().getOrderFor("exerciseCount").getDirection())
+ .isEqualTo(org.springframework.data.domain.Sort.Direction.DESC);
+ }
+
+ @Test
+ @DisplayName("실패 - 유효하지 않은 정렬 기준을 전달하면 INVALID_ORDER_TYPE 발생")
+ void fail_getMyParties_invalidSort() {
+ // given
+ Long memberId = 10L;
+ Pageable pageable = PageRequest.of(0, 10);
+
+ // when & then
+ assertThatThrownBy(() -> partyQueryService.getMyParties(memberId, false, "존재하지않는정렬", pageable))
+ .isInstanceOf(PartyException.class)
+ .satisfies(e -> assertThat(((PartyException) e).getCode())
+ .isEqualTo(PartyErrorCode.INVALID_ORDER_TYPE));
+ }
+ }
+
+ @Nested
+ @DisplayName("getSimpleMyParties")
+ class GetSimpleMyParties {
+
+ @Test
+ @DisplayName("성공 - 유효한 회원 ID가 주어지면 가입한 모임 3개의 간략화된 목록을 반환한다")
+ void success_getSimpleMyParties() {
+ // given
+ Long memberId = 1L;
+ Pageable pageable = PageRequest.of(0, 10);
+
+ Member member = MemberFixture.createMember("사용자", Gender.MALE, Level.A, 1001L);
+ ReflectionTestUtils.setField(member, "id", memberId);
+ PartyAddr addr = PartyFixture.createPartyAddr("서울특별시", "강남구");
+
+ Party party1 = PartyFixture.createParty("모임1", 10L, addr);
+ ReflectionTestUtils.setField(party1, "id", 1L);
+ Party party2 = PartyFixture.createParty("모임2", 10L, addr);
+ ReflectionTestUtils.setField(party2, "id", 2L);
+ Party party3 = PartyFixture.createParty("모임3", 10L, addr);
+ ReflectionTestUtils.setField(party3, "id", 3L);
+
+ MemberParty mp1 = MemberFixture.createMemberParty(party1, member, Role.PARTY_MEMBER);
+ MemberParty mp2 = MemberFixture.createMemberParty(party2, member, Role.PARTY_MEMBER);
+ MemberParty mp3 = MemberFixture.createMemberParty(party3, member, Role.PARTY_MEMBER);
+
+ Slice memberPartySlice = new SliceImpl<>(List.of(mp1, mp2, mp3), pageable, false);
+
+ given(memberRepository.findById(memberId)).willReturn(Optional.of(member));
+ given(memberPartyRepository.findByMember(member, pageable)).willReturn(memberPartySlice);
+
+ // when
+ Slice result = partyQueryService.getSimpleMyParties(memberId, pageable);
+
+ // then
+ assertThat(result.getContent()).hasSize(3);
+ assertThat(result.getContent().get(0).partyId()).isEqualTo(1L);
+ assertThat(result.getContent().get(1).partyId()).isEqualTo(2L);
+ assertThat(result.getContent().get(2).partyId()).isEqualTo(3L);
+
+ verify(memberRepository).findById(memberId);
+ verify(memberPartyRepository).findByMember(member, pageable);
+ }
+
+ @Test
+ @DisplayName("실패 - 존재하지 않는 회원일 경우 MemberException을 던진다")
+ void fail_getSimpleMyParties_memberNotFound() {
+ // given
+ Long invalidMemberId = 999L;
+ Pageable pageable = PageRequest.of(0, 10);
+
+ given(memberRepository.findById(invalidMemberId)).willReturn(Optional.empty());
+
+ // when & then
+ assertThatThrownBy(() -> partyQueryService.getSimpleMyParties(invalidMemberId, pageable))
+ .isInstanceOf(MemberException.class)
+ .satisfies(e -> assertThat(
+ ((MemberException) e)
+ .getCode())
+ .isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND));
+ }
+ }
+
+ @Nested
+ @DisplayName("getRecommendedParties")
+ class GetRecommendedParties {
+
+ @Test
+ @DisplayName("성공 - Cockple 추천 모드 시 유저 정보(주소, 생년월일, 키워드)를 기반으로 추천 목록을 반환한다")
+ void success_getRecommendedParties_cockpleRecommend() {
+ // given
+ Long memberId = 1L;
+ Pageable pageable = PageRequest.of(0, 10);
+ PartyFilterDTO.Request filter = PartyFilterDTO.Request.builder().build();
+
+ Member member = MemberFixture.createMember("매니저", Gender.MALE, Level.A, 1001L,
+ LocalDate.of(1995, 1, 1));
+ ReflectionTestUtils.setField(member, "id", memberId);
+
+ MemberAddr addr = MemberAddr.builder()
+ .member(member)
+ .addr1("서울특별시")
+ .isMain(true)
+ .build();
+
+ Party suggestedParty = PartyFixture.createParty("추천 모임", 2L,
+ PartyFixture.createPartyAddr("서울특별시", "강남구"));
+ ReflectionTestUtils.setField(suggestedParty, "id", 100L);
+
+ given(memberRepository.findById(memberId)).willReturn(Optional.of(member));
+ given(memberAddrRepository.findByMemberAndIsMain(member, true)).willReturn(Optional.of(addr));
+ given(partyRepository.findRecommendedParties(anyString(), anyInt(), any(), any(), anyLong()))
+ .willReturn(List.of(suggestedParty));
+ given(partyBookmarkRepository.findAllPartyIdsByMemberId(memberId)).willReturn(Set.of());
+
+ // when
+ Slice result = partyQueryService.getRecommendedParties(memberId, true,
+ filter, "최신순", pageable);
+
+ // then
+ assertThat(result.getContent()).hasSize(1);
+ assertThat(result.getContent().get(0).partyName()).isEqualTo("추천 모임");
+ verify(partyRepository).findRecommendedParties(eq("서울특별시"), eq(1995), eq(Gender.MALE),
+ eq(Level.A), eq(memberId));
+ }
+
+ @Test
+ @DisplayName("성공 - 필터 모드 시 조건에 맞는 모임 목록을 최신순으로 반환한다")
+ void success_getRecommendedParties_latest() {
+ // given
+ Long memberId = 1L;
+ Pageable pageable = PageRequest.of(0, 10);
+ PartyFilterDTO.Request filter = PartyFilterDTO.Request.builder().addr1("서울특별시").build();
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울특별시", "강남구");
+ Party p1 = PartyFixture.createParty("모임1", 2L, addr);
+ ReflectionTestUtils.setField(p1, "id", 1L);
+ Party p2 = PartyFixture.createParty("모임2", 2L, addr);
+ ReflectionTestUtils.setField(p2, "id", 2L);
+ Party p3 = PartyFixture.createParty("모임3", 2L, addr);
+ ReflectionTestUtils.setField(p3, "id", 3L);
+
+ Slice partySlice = new SliceImpl<>(List.of(p3, p2, p1), pageable, false);
+
+ given(partyRepository.searchParties(eq(memberId), eq(filter), any(Pageable.class)))
+ .willReturn(partySlice);
+ given(partyBookmarkRepository.findAllPartyIdsByMemberId(memberId)).willReturn(Set.of());
+
+ // when
+ Slice result = partyQueryService.getRecommendedParties(memberId, false, filter, "최신순", pageable);
+
+ // then
+ assertThat(result.getContent()).hasSize(3);
+ assertThat(result.getContent().get(0).partyId()).isEqualTo(3L);
+
+ org.mockito.ArgumentCaptor captor = org.mockito.ArgumentCaptor.forClass(Pageable.class);
+ verify(partyRepository).searchParties(eq(memberId), eq(filter), captor.capture());
+ assertThat(captor.getValue().getSort().getOrderFor("createdAt").getDirection())
+ .isEqualTo(org.springframework.data.domain.Sort.Direction.DESC);
+ }
+
+ @Test
+ @DisplayName("성공 - 필터 모드 시 조건에 맞는 모임 목록을 오래된 순으로 반환한다")
+ void success_getRecommendedParties_oldest() {
+ // given
+ Long memberId = 1L;
+ Pageable pageable = PageRequest.of(0, 10);
+ PartyFilterDTO.Request filter = PartyFilterDTO.Request.builder().build();
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울특별시", "강남구");
+ Party p1 = PartyFixture.createParty("모임1", 2L, addr);
+ ReflectionTestUtils.setField(p1, "id", 1L);
+ Party p2 = PartyFixture.createParty("모임2", 2L, addr);
+ ReflectionTestUtils.setField(p2, "id", 2L);
+ Party p3 = PartyFixture.createParty("모임3", 2L, addr);
+ ReflectionTestUtils.setField(p3, "id", 3L);
+
+ Slice partySlice = new SliceImpl<>(List.of(p1, p2, p3), pageable, false);
+
+ given(partyRepository.searchParties(eq(memberId), eq(filter), any(Pageable.class)))
+ .willReturn(partySlice);
+ given(partyBookmarkRepository.findAllPartyIdsByMemberId(memberId)).willReturn(Set.of());
+
+ // when
+ Slice result = partyQueryService.getRecommendedParties(memberId, false, filter, "오래된 순", pageable);
+
+ // then
+ assertThat(result.getContent()).hasSize(3);
+ assertThat(result.getContent().get(0).partyId()).isEqualTo(1L);
+
+ org.mockito.ArgumentCaptor captor = org.mockito.ArgumentCaptor.forClass(Pageable.class);
+ verify(partyRepository).searchParties(eq(memberId), eq(filter), captor.capture());
+ assertThat(captor.getValue().getSort().getOrderFor("createdAt").getDirection())
+ .isEqualTo(org.springframework.data.domain.Sort.Direction.ASC);
+ }
+
+ @Test
+ @DisplayName("성공 - 필터 모드 시 조건에 맞는 모임 목록을 운동 많은 순으로 반환한다")
+ void success_getRecommendedParties_exerciseCount() {
+ // given
+ Long memberId = 1L;
+ Pageable pageable = PageRequest.of(0, 10);
+ PartyFilterDTO.Request filter = PartyFilterDTO.Request.builder().build();
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울특별시", "강남구");
+ Party p1 = PartyFixture.createParty("모임1", 2L, addr);
+ ReflectionTestUtils.setField(p1, "id", 1L);
+ Party p2 = PartyFixture.createParty("모임2", 2L, addr);
+ ReflectionTestUtils.setField(p2, "id", 2L);
+ Party p3 = PartyFixture.createParty("모임3", 2L, addr);
+ ReflectionTestUtils.setField(p3, "id", 3L);
+
+ Slice partySlice = new SliceImpl<>(List.of(p2, p1, p3), pageable, false); // 20회, 10회, 5회 가정
+
+ given(partyRepository.searchParties(eq(memberId), eq(filter), any(Pageable.class)))
+ .willReturn(partySlice);
+ given(partyBookmarkRepository.findAllPartyIdsByMemberId(memberId)).willReturn(Set.of());
+
+ // when
+ Slice result = partyQueryService.getRecommendedParties(memberId, false, filter, "운동 많은 순", pageable);
+
+ // then
+ assertThat(result.getContent()).hasSize(3);
+ assertThat(result.getContent().get(0).partyId()).isEqualTo(2L);
+
+ org.mockito.ArgumentCaptor captor = org.mockito.ArgumentCaptor.forClass(Pageable.class);
+ verify(partyRepository).searchParties(eq(memberId), eq(filter), captor.capture());
+ assertThat(captor.getValue().getSort().getOrderFor("exerciseCount").getDirection())
+ .isEqualTo(org.springframework.data.domain.Sort.Direction.DESC);
+ }
+
+ @Test
+ @DisplayName("성공 - 검색 모드 시 검색 키워드에 맞는 모임 목록을 반환한다")
+ void success_getRecommendedParties_search() {
+ // given
+ Long memberId = 1L;
+ Pageable pageable = PageRequest.of(0, 10);
+ PartyFilterDTO.Request filter = PartyFilterDTO.Request.builder().search("검색값").build();
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울특별시", "강남구");
+ Party party = PartyFixture.createParty("검색결과모임", 2L, addr);
+ ReflectionTestUtils.setField(party, "id", 100L);
+ Slice partySlice = new SliceImpl<>(List.of(party), pageable, false);
+
+ given(partyRepository.searchParties(eq(memberId), eq(filter), any(Pageable.class)))
+ .willReturn(partySlice);
+ given(partyBookmarkRepository.findAllPartyIdsByMemberId(memberId)).willReturn(Set.of());
+
+ // when
+ Slice result = partyQueryService.getRecommendedParties(memberId, false, filter, "최신순", pageable);
+
+ // then
+ assertThat(result.getContent()).hasSize(1);
+ assertThat(result.getContent().get(0).partyName()).isEqualTo("검색결과모임");
+ verify(partyRepository).searchParties(eq(memberId), eq(filter), any(Pageable.class));
+ }
+
+ @Test
+ @DisplayName("실패 - 존재하지 않는 회원 ID로 추천 요청 시 MEMBER_NOT_FOUND이 발생한다")
+ void fail_getRecommendedParties_memberNotFound() {
+ // given
+ Long memberId = 999L;
+ Pageable pageable = PageRequest.of(0, 10);
+ PartyFilterDTO.Request filter = PartyFilterDTO.Request.builder().build();
+
+ given(memberRepository.findById(memberId)).willReturn(Optional.empty());
+
+ // when & then
+ assertThatThrownBy(() -> partyQueryService.getRecommendedParties(memberId, true, filter, "최신순",
+ pageable))
+ .isInstanceOf(MemberException.class)
+ .satisfies(e -> assertThat(
+ ((MemberException) e)
+ .getCode())
+ .isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND));
+ }
+
+ @Test
+ @DisplayName("실패 - 대표 주소가 설정되지 않은 회원이 추천 요청 시 MAIN_ADDRESS_NULL이 발생한다")
+ void fail_getRecommendedParties_mainAddressNotFound() {
+ // given
+ Long memberId = 1L;
+ Pageable pageable = PageRequest.of(0, 10);
+ PartyFilterDTO.Request filter = PartyFilterDTO.Request.builder().build();
+
+ Member member = MemberFixture.createMember("매니저", Gender.MALE, Level.A, 1001L);
+ ReflectionTestUtils.setField(member, "id", memberId);
+
+ given(memberRepository.findById(memberId)).willReturn(Optional.of(member));
+ given(memberAddrRepository.findByMemberAndIsMain(member, true)).willReturn(Optional.empty());
+
+ // when & then
+ assertThatThrownBy(() -> partyQueryService.getRecommendedParties(memberId, true, filter, "최신순",
+ pageable))
+ .isInstanceOf(MemberException.class)
+ .satisfies(e -> assertThat(
+ ((MemberException) e)
+ .getCode())
+ .isEqualTo(MemberErrorCode.MAIN_ADDRESS_NULL));
+ }
+ }
+
+ @Nested
+ @DisplayName("getPartyDetails")
+ class GetPartyDetails {
+
+ @Test
+ @DisplayName("성공 - 모임 상세 정보를 정상적으로 조회한다 (비회원, 신청 전)")
+ void success_getPartyDetails_nonMember() {
+ // given
+ Long partyId = 1L;
+ Long memberId = 10L;
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남");
+ Party party = PartyFixture.createParty("상세 모임", 11L, addr);
+ ReflectionTestUtils.setField(party, "id", partyId);
+ Member member = MemberFixture.createMember("사용자", Gender.MALE, Level.A, 1000L);
+ ReflectionTestUtils.setField(member, "id", memberId);
+
+ PartyDetailDTO.Response expected = PartyDetailDTO.Response.builder()
+ .partyId(partyId)
+ .partyName("상세 모임")
+ .memberStatus("NOT_MEMBER")
+ .hasPendingJoinRequest(false)
+ .build();
+
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+ given(memberRepository.findById(memberId)).willReturn(Optional.of(member));
+ given(memberPartyRepository.findByPartyAndMember(party, member)).willReturn(Optional.empty());
+ given(partyBookmarkRepository.existsByMemberAndParty(member, party)).willReturn(false);
+ given(partyJoinRequestRepository.existsByPartyAndMemberAndStatus(party, member,
+ RequestStatus.PENDING)).willReturn(false);
+
+ // when
+ PartyDetailDTO.Response result = partyQueryService.getPartyDetails(partyId, memberId);
+
+ // then
+ assertThat(result.partyId()).isEqualTo(partyId);
+ assertThat(result.partyName()).isEqualTo("상세 모임");
+ assertThat(result.memberStatus()).isEqualTo("NOT_MEMBER");
+ assertThat(result.hasPendingJoinRequest()).isFalse();
+ assertThat(result.isBookmarked()).isFalse();
+ verify(partyRepository).findById(partyId);
+ }
+
+ @Test
+ @DisplayName("성공 - 모임원인 경우 memberStatus가 MEMBER로 반환된다")
+ void success_getPartyDetails_member() {
+ // given
+ Long partyId = 1L;
+ Long memberId = 10L;
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남");
+ Party party = PartyFixture.createParty("상세 모임", 11L, addr);
+ ReflectionTestUtils.setField(party, "id", partyId);
+ Member member = MemberFixture.createMember("사용자", Gender.MALE, Level.A, 1000L);
+ ReflectionTestUtils.setField(member, "id", memberId);
+
+ MemberParty memberParty = MemberFixture.createMemberParty(party, member, Role.PARTY_MEMBER);
+ PartyDetailDTO.Response expected = PartyDetailDTO.Response.builder()
+ .partyId(partyId)
+ .memberStatus("MEMBER")
+ .memberRole("PARTY_MEMBER")
+ .build();
+
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+ given(memberRepository.findById(memberId)).willReturn(Optional.of(member));
+ given(memberPartyRepository.findByPartyAndMember(party, member))
+ .willReturn(Optional.of(memberParty));
+ given(partyBookmarkRepository.existsByMemberAndParty(member, party)).willReturn(true);
+
+ // when
+ PartyDetailDTO.Response result = partyQueryService.getPartyDetails(partyId, memberId);
+
+ // then
+ assertThat(result.memberStatus()).isEqualTo("MEMBER");
+ assertThat(result.memberRole()).isEqualTo("PARTY_MEMBER");
+ assertThat(result.hasPendingJoinRequest()).isNull(); // 멤버이므로 null 반환
+ assertThat(result.isBookmarked()).isTrue();
+ }
+
+ @Test
+ @DisplayName("실패 - 존재하지 않는 모임 조회 시 PARTY_NOT_FOUND이 발생한다")
+ void fail_getPartyDetails_partyNotFound() {
+ // given
+ given(partyRepository.findById(999L)).willReturn(Optional.empty());
+
+ // when & then
+ assertThatThrownBy(() -> partyQueryService.getPartyDetails(999L, 1L))
+ .isInstanceOf(PartyException.class)
+ .satisfies(e -> assertThat(((PartyException) e).getCode())
+ .isEqualTo(PartyErrorCode.PARTY_NOT_FOUND));
+ }
+
+ @Test
+ @DisplayName("실패 - 삭제된 모임 조회 시 PARTY_IS_DELETED이 발생한다")
+ void fail_getPartyDetails_partyDeleted() {
+ // given
+ PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남");
+ Party party = PartyFixture.createParty("삭제된 모임", 11L, addr);
+ party.delete();
+ given(partyRepository.findById(1L)).willReturn(Optional.of(party));
+ given(memberRepository.findById(1L)).willReturn(
+ Optional.of(MemberFixture.createMember("테스터", Gender.MALE, Level.A, 1L)));
+
+ // when & then
+ assertThatThrownBy(() -> partyQueryService.getPartyDetails(1L, 1L))
+ .isInstanceOf(PartyException.class)
+ .satisfies(e -> assertThat(((PartyException) e).getCode())
+ .isEqualTo(PartyErrorCode.PARTY_IS_DELETED));
+ }
+
+ @Test
+ @DisplayName("실패 - 존재하지 않는 회원인 경우 MEMBER_NOT_FOUND 예외 발생")
+ void fail_getPartyDetails_memberNotFound() {
+ // given
+ Long partyId = 1L;
+ Party party = PartyFixture.createParty("상세 모임", 11L, null);
+ ReflectionTestUtils.setField(party, "id", partyId);
+
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+ given(memberRepository.findById(1L)).willReturn(Optional.empty());
+
+ // when & then
+ assertThatThrownBy(() -> partyQueryService.getPartyDetails(partyId, 1L))
+ .isInstanceOf(MemberException.class)
+ .satisfies(e -> assertThat(((MemberException) e).getCode())
+ .isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND));
+ }
+ }
+
+ @Nested
+ @DisplayName("getJoinRequests")
+ class GetJoinRequests {
+
+ @Test
+ @DisplayName("성공 - 모임장이 가입 신청 목록을 정상적으로 조회한다")
+ void success_getJoinRequests() {
+ // given
+ Long partyId = 1L;
+ Long ownerId = 10L;
+ Pageable pageable = PageRequest.of(0, 10);
+ String status = "PENDING";
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남");
+ Party party = PartyFixture.createParty("모임명", ownerId, addr);
+ ReflectionTestUtils.setField(party, "id", partyId);
+
+ Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, ownerId);
+ ReflectionTestUtils.setField(owner, "id", ownerId);
+ Member applicant = MemberFixture.createMember("지원자", Gender.FEMALE, Level.B, 20L);
+ ReflectionTestUtils.setField(applicant, "id", 20L);
+
+ PartyJoinRequest joinRequest = PartyJoinRequest.builder()
+ .party(party)
+ .member(applicant)
+ .status(RequestStatus.PENDING)
+ .build();
+ ReflectionTestUtils.setField(joinRequest, "id", 100L);
+
+ Slice requestSlice = new SliceImpl<>(List.of(joinRequest), pageable, false);
+
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+
+ given(partyJoinRequestRepository.findByPartyAndStatus(party, RequestStatus.PENDING, pageable))
+ .willReturn(requestSlice);
+
+ // when
+ Slice result = partyQueryService.getJoinRequests(partyId, ownerId, status, pageable);
+
+ // then
+ assertThat(result.getContent()).hasSize(1);
+ assertThat(result.getContent().get(0).joinRequestId()).isEqualTo(100L);
+ verify(partyJoinRequestRepository).findByPartyAndStatus(party, RequestStatus.PENDING, pageable);
+ }
+
+ @Test
+ @DisplayName("성공 - 모임장이 가입 승인된 멤버 목록(APPROVED)을 정상적으로 조회한다")
+ void success_getJoinRequests_approved() {
+ // given
+ Long partyId = 1L;
+ Long ownerId = 10L;
+ Pageable pageable = PageRequest.of(0, 10);
+ String status = "APPROVED";
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남");
+ Party party = PartyFixture.createParty("모임명", ownerId, addr);
+ ReflectionTestUtils.setField(party, "id", partyId);
+
+ Member applicant = MemberFixture.createMember("지원자", Gender.FEMALE, Level.B, 20L);
+ ReflectionTestUtils.setField(applicant, "id", 20L);
+
+ PartyJoinRequest joinRequest = PartyJoinRequest.builder()
+ .party(party)
+ .member(applicant)
+ .status(RequestStatus.APPROVED)
+ .build();
+ ReflectionTestUtils.setField(joinRequest, "id", 101L);
+
+ Slice requestSlice = new SliceImpl<>(List.of(joinRequest), pageable, false);
+
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+
+ given(partyJoinRequestRepository.findByPartyAndStatus(party, RequestStatus.APPROVED, pageable))
+ .willReturn(requestSlice);
+
+ // when
+ Slice result = partyQueryService.getJoinRequests(partyId, ownerId, status, pageable);
+
+ // then
+ assertThat(result.getContent()).hasSize(1);
+ assertThat(result.getContent().get(0).joinRequestId()).isEqualTo(101L);
+ verify(partyJoinRequestRepository).findByPartyAndStatus(party, RequestStatus.APPROVED, pageable);
+ }
+
+ @Test
+ @DisplayName("실패 - 모임장이 아닌 사용자가 조회하면 INSUFFICIENT_PERMISSION 발생")
+ void fail_getJoinRequests_notOwner() {
+ // given
+ Long partyId = 1L;
+ Long nonOwnerId = 20L;
+ Pageable pageable = PageRequest.of(0, 10);
+ String status = "PENDING";
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남");
+ Party party = PartyFixture.createParty("모임명", 10L, addr);
+ ReflectionTestUtils.setField(party, "id", partyId);
+
+ Member nonOwner = MemberFixture.createMember("일반멤버", Gender.FEMALE, Level.B, nonOwnerId);
+ ReflectionTestUtils.setField(nonOwner, "id", nonOwnerId);
+ MemberParty nonOwnerParty = MemberFixture.createMemberParty(party, nonOwner, Role.PARTY_MEMBER);
+
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+
+
+ // when & then
+ assertThatThrownBy(() -> partyQueryService.getJoinRequests(partyId, nonOwnerId, status, pageable))
+ .isInstanceOf(PartyException.class)
+ .satisfies(e -> assertThat(((PartyException) e).getCode()).isEqualTo(PartyErrorCode.INSUFFICIENT_PERMISSION));
+ }
+
+ @Test
+ @DisplayName("실패 - 잘못된 상태값을 입력하면 INVALID_REQUEST_STATUS 발생")
+ void fail_getJoinRequests_invalidStatus() {
+ // given
+ Long partyId = 1L;
+ Long ownerId = 10L;
+ Pageable pageable = PageRequest.of(0, 10);
+ String invalidStatus = "UNKNOWN";
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남");
+ Party party = PartyFixture.createParty("모임명", ownerId, addr);
+ ReflectionTestUtils.setField(party, "id", partyId);
+
+ Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, ownerId);
+ ReflectionTestUtils.setField(owner, "id", ownerId);
+ MemberParty ownerParty = MemberFixture.createMemberParty(party, owner, Role.PARTY_MANAGER);
+
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+
+
+ // when & then
+ assertThatThrownBy(() -> partyQueryService.getJoinRequests(partyId, ownerId, invalidStatus, pageable))
+ .isInstanceOf(PartyException.class)
+ .satisfies(e -> assertThat(((PartyException) e).getCode()).isEqualTo(PartyErrorCode.INVALID_REQUEST_STATUS));
+ }
+
+ @Test
+ @DisplayName("실패 - 존재하지 않는 파티인 경우 PARTY_NOT_FOUND 예외 발생")
+ void fail_getJoinRequests_partyNotFound() {
+ // given
+ Long invalidId = 999L;
+ given(partyRepository.findById(invalidId)).willReturn(Optional.empty());
+
+ // when & then
+ assertThatThrownBy(() -> partyQueryService.getJoinRequests(invalidId, 1L, "PENDING", PageRequest.of(0, 10)))
+ .isInstanceOf(PartyException.class)
+ .satisfies(e -> assertThat(((PartyException) e).getCode())
+ .isEqualTo(PartyErrorCode.PARTY_NOT_FOUND));
}
}
+ @Nested
+ @DisplayName("getRecommendedMembers")
+ class GetRecommendedMembers {
+
+ @Test
+ @DisplayName("성공 - 조건에 맞는 추천 멤버 목록을 정상적으로 조회한다")
+ void success_getRecommendedMembers() {
+ // given
+ Long partyId = 1L;
+ String levelSearch = "B";
+ Pageable pageable = PageRequest.of(0, 10);
+
+ PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남");
+ Party party = PartyFixture.createParty("모임명", 1L, addr);
+ ReflectionTestUtils.setField(party, "id", partyId);
+
+ Member suggestedMember = MemberFixture.createMember("추천회원", Gender.MALE, Level.B, 20L);
+ ReflectionTestUtils.setField(suggestedMember, "id", 20L);
+
+ Slice membersSlice = new SliceImpl<>(List.of(suggestedMember), pageable, false);
+
+ given(partyRepository.findById(partyId)).willReturn(Optional.of(party));
+ given(memberRepository.findRecommendedMembers(party, levelSearch, pageable))
+ .willReturn(membersSlice);
+
+ // when
+ Slice result = partyQueryService.getRecommendedMembers(partyId, levelSearch, pageable);
+
+ // then
+ assertThat(result.getContent()).hasSize(1);
+ assertThat(result.getContent().get(0).userId()).isEqualTo(20L);
+ verify(memberRepository).findRecommendedMembers(party, levelSearch, pageable);
+ }
+
+ @Test
+ @DisplayName("실패 - 존재하지 않는 모임의 추천 멤버를 조회하면 PARTY_NOT_FOUND 발생")
+ void fail_getRecommendedMembers_partyNotFound() {
+ // given
+ Long partyId = 999L;
+ String levelSearch = null;
+ Pageable pageable = PageRequest.of(0, 10);
+
+ given(partyRepository.findById(partyId)).willReturn(Optional.empty());
+
+ // when & then
+ PartyException exception = assertThrows(PartyException.class,
+ () -> partyQueryService.getRecommendedMembers(partyId, levelSearch, pageable));
+ assertThat(exception.getCode()).isEqualTo(PartyErrorCode.PARTY_NOT_FOUND);
+ }
+ }
}
diff --git a/src/test/java/umc/cockple/demo/support/ExerciseCalendarTestHelper.java b/src/test/java/umc/cockple/demo/support/ExerciseCalendarTestHelper.java
new file mode 100644
index 000000000..9a4dccaa2
--- /dev/null
+++ b/src/test/java/umc/cockple/demo/support/ExerciseCalendarTestHelper.java
@@ -0,0 +1,30 @@
+package umc.cockple.demo.support;
+
+import java.time.LocalDate;
+import java.time.temporal.ChronoUnit;
+
+public final class ExerciseCalendarTestHelper {
+
+ private ExerciseCalendarTestHelper() {
+ }
+
+ public static LocalDate expectedDefaultStartDate() {
+ LocalDate today = LocalDate.now();
+ LocalDate thisWeekMonday = today.minusDays(today.getDayOfWeek().getValue() - 1L);
+ return thisWeekMonday.minusWeeks(1);
+ }
+
+ public static LocalDate expectedDefaultEndDate() {
+ LocalDate today = LocalDate.now();
+ LocalDate thisWeekMonday = today.minusDays(today.getDayOfWeek().getValue() - 1L);
+ return thisWeekMonday.plusWeeks(3).plusDays(6);
+ }
+
+ public static int weekIndexFor(LocalDate expectedStart, LocalDate targetDate) {
+ return (int) (ChronoUnit.DAYS.between(expectedStart, targetDate) / 7);
+ }
+
+ public static int dayIndexFor(LocalDate targetDate) {
+ return targetDate.getDayOfWeek().getValue() - 1;
+ }
+}
diff --git a/src/test/java/umc/cockple/demo/support/IntegrationTestConfig.java b/src/test/java/umc/cockple/demo/support/IntegrationTestConfig.java
index e245f83e5..097910997 100644
--- a/src/test/java/umc/cockple/demo/support/IntegrationTestConfig.java
+++ b/src/test/java/umc/cockple/demo/support/IntegrationTestConfig.java
@@ -1,5 +1,6 @@
package umc.cockple.demo.support;
+import com.google.firebase.messaging.FirebaseMessaging;
import com.redis.testcontainers.RedisContainer;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
@@ -7,11 +8,14 @@
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.utility.DockerImageName;
+import static org.mockito.Mockito.mock;
+
@TestConfiguration(proxyBeanMethods = false)
public class IntegrationTestConfig {
private static final MySQLContainer> mysql =
- new MySQLContainer<>("mysql:8.0.36");
+ new MySQLContainer<>("mysql:8.0.36")
+ .withConfigurationOverride("mysql-conf");
private static final RedisContainer redis =
new RedisContainer(DockerImageName.parse("redis:7.2-alpine"));
@@ -32,4 +36,9 @@ MySQLContainer> mySQLContainer() {
RedisContainer redisContainer() {
return redis;
}
+
+ @Bean
+ FirebaseMessaging firebaseMessaging() {
+ return mock(FirebaseMessaging.class);
+ }
}
diff --git a/src/test/java/umc/cockple/demo/support/fixture/ChatFixture.java b/src/test/java/umc/cockple/demo/support/fixture/ChatFixture.java
index 89fc4cc3d..e5a1a97ec 100644
--- a/src/test/java/umc/cockple/demo/support/fixture/ChatFixture.java
+++ b/src/test/java/umc/cockple/demo/support/fixture/ChatFixture.java
@@ -1,6 +1,7 @@
package umc.cockple.demo.support.fixture;
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.domain.ChatRoomMember;
import umc.cockple.demo.domain.chat.enums.ChatRoomMemberStatus;
@@ -9,6 +10,8 @@
import umc.cockple.demo.domain.member.domain.Member;
import umc.cockple.demo.domain.party.domain.Party;
+import java.util.List;
+
public class ChatFixture {
public static ChatRoom createPartyChatRoom(Party party) {
@@ -28,6 +31,16 @@ public static ChatRoomMember createJoinedMember(ChatRoom chatRoom, Member member
return ChatRoomMember.create(chatRoom, member);
}
+ public static ChatRoomMember createJoinedMember(ChatRoom chatRoom, Member member, String displayName) {
+ return ChatRoomMember.createJoined(chatRoom, member, displayName);
+ }
+
+ public static ChatRoomMember createChatRoomMemberWithDisplayName(String displayName) {
+ return ChatRoomMember.builder()
+ .displayName(displayName)
+ .build();
+ }
+
public static ChatRoomMember createJoinedMemberWithLastRead(ChatRoom chatRoom, Member member, Long lastReadMessageId) {
return ChatRoomMember.builder()
.chatRoom(chatRoom)
@@ -40,4 +53,14 @@ public static ChatRoomMember createJoinedMemberWithLastRead(ChatRoom chatRoom, M
public static ChatMessage createTextMessage(ChatRoom chatRoom, Member sender, String content) {
return ChatMessage.create(chatRoom, sender, content, MessageType.TEXT);
}
+
+ public static ChatMessage createImageMessage(ChatRoom chatRoom, Member sender, List files) {
+ ChatMessage message = ChatMessage.create(chatRoom, sender, null, MessageType.TEXT);
+ message.getChatMessageFiles().addAll(files);
+ return message;
+ }
+
+ public static ChatMessageFile createChatMessageFile(ChatMessage message, String fileKey, int fileOrder, String originalFileName) {
+ return ChatMessageFile.create(message, fileKey, fileOrder, originalFileName, 1024L, "image/png");
+ }
}
\ No newline at end of file
diff --git a/src/test/java/umc/cockple/demo/support/fixture/ExerciseFixture.java b/src/test/java/umc/cockple/demo/support/fixture/ExerciseFixture.java
index e82fd338f..1780fcc17 100644
--- a/src/test/java/umc/cockple/demo/support/fixture/ExerciseFixture.java
+++ b/src/test/java/umc/cockple/demo/support/fixture/ExerciseFixture.java
@@ -9,39 +9,71 @@
public class ExerciseFixture {
- public static Exercise createExercise(Party party, LocalDate date) {
+ public static ExerciseAddr createExerciseAddr(String buildingName, String streetAddr,
+ double latitude, double longitude) {
+ return ExerciseAddr.builder()
+ .addr1("서울특별시")
+ .addr2("강남구")
+ .streetAddr(streetAddr)
+ .buildingName(buildingName)
+ .latitude(latitude)
+ .longitude(longitude)
+ .build();
+ }
+
+ public static ExerciseAddr createExerciseAddr() {
+ return createExerciseAddr("테스트 체육관", "서울특별시 강남구 테헤란로 1");
+ }
+
+ public static ExerciseAddr createExerciseAddr(String buildingName, String streetAddr) {
+ return createExerciseAddr(buildingName, streetAddr, 37.5, 127.0);
+ }
+
+ private static Exercise createExercise(Party party, LocalDate date, LocalTime endTime,
+ int maxCapacity, boolean partyGuestAccept,
+ boolean outsideGuestAccept, ExerciseAddr exerciseAddr,
+ String notice) {
return Exercise.builder()
.party(party)
.date(date)
.startTime(LocalTime.of(10, 0))
- .maxCapacity(10)
- .partyGuestAccept(true)
- .outsideGuestAccept(false)
+ .endTime(endTime)
+ .maxCapacity(maxCapacity)
+ .partyGuestAccept(partyGuestAccept)
+ .outsideGuestAccept(outsideGuestAccept)
+ .exerciseAddr(exerciseAddr)
+ .notice(notice)
.build();
}
+ public static Exercise createExercise(Party party, LocalDate date) {
+ return createExercise(party, date, null, true, false);
+ }
+
+ public static Exercise createExercise(Party party, LocalDate date, LocalTime endTime,
+ boolean partyGuestAccept, boolean outsideGuestAccept) {
+ return createExercise(party, date, endTime, 10, partyGuestAccept, outsideGuestAccept,
+ null, null);
+ }
+
public static Exercise createExerciseWithAddr(Party party, LocalDate date) {
return createExerciseWithAddr(party, date, 10);
}
public static Exercise createExerciseWithAddr(Party party, LocalDate date, int maxCapacity) {
- ExerciseAddr addr = ExerciseAddr.builder()
- .addr1("서울특별시")
- .addr2("강남구")
- .streetAddr("서울특별시 강남구 테헤란로 1")
- .buildingName("테스트 체육관")
- .latitude(37.5)
- .longitude(127.0)
- .build();
+ return createExercise(party, date, null, maxCapacity, true, false,
+ createExerciseAddr(), null);
+ }
- return Exercise.builder()
- .party(party)
- .date(date)
- .startTime(LocalTime.of(10, 0))
- .maxCapacity(maxCapacity)
- .partyGuestAccept(true)
- .outsideGuestAccept(false)
- .exerciseAddr(addr)
- .build();
+ public static Exercise createRecommendableExercise(Party party, LocalDate date,
+ double latitude, double longitude,
+ String buildingName) {
+ return createExercise(party, date, LocalTime.of(12, 0), 10, true, true,
+ createExerciseAddr(buildingName, "테헤란로 1", latitude, longitude), null);
+ }
+
+ public static Exercise createExerciseForEdit(Party party, LocalDate date) {
+ return createExercise(party, date, LocalTime.of(12, 30), 18, true, false,
+ createExerciseAddr(), "수정 공지사항");
}
}
diff --git a/src/test/java/umc/cockple/demo/support/fixture/GuestFixture.java b/src/test/java/umc/cockple/demo/support/fixture/GuestFixture.java
index 1fd4044d2..23ccffb05 100644
--- a/src/test/java/umc/cockple/demo/support/fixture/GuestFixture.java
+++ b/src/test/java/umc/cockple/demo/support/fixture/GuestFixture.java
@@ -8,13 +8,17 @@
public class GuestFixture {
public static Guest createGuest(Exercise exercise, Long inviterId) {
+ return createGuest(exercise, inviterId, "게스트", Gender.MALE);
+ }
+
+ public static Guest createGuest(Exercise exercise, Long inviterId, String guestName, Gender gender) {
Guest guest = Guest.builder()
- .guestName("게스트")
- .gender(Gender.MALE)
+ .guestName(guestName)
+ .gender(gender)
.level(Level.B)
.inviterId(inviterId)
.build();
guest.setExercise(exercise);
return guest;
}
-}
\ No newline at end of file
+}
diff --git a/src/test/java/umc/cockple/demo/support/fixture/MemberAddrFixture.java b/src/test/java/umc/cockple/demo/support/fixture/MemberAddrFixture.java
new file mode 100644
index 000000000..6fb42611d
--- /dev/null
+++ b/src/test/java/umc/cockple/demo/support/fixture/MemberAddrFixture.java
@@ -0,0 +1,115 @@
+package umc.cockple.demo.support.fixture;
+
+import umc.cockple.demo.domain.member.domain.Member;
+import umc.cockple.demo.domain.member.domain.MemberAddr;
+
+public class MemberAddrFixture {
+
+ /**
+ * 임의 테스트 주소
+ * 대표주소
+ * - 서울특별시 강남구 역삼동 테헤란로 123 / ㅁㅁ빌딩 (37.5, 127.0)
+ */
+ public static MemberAddr createMainAddr(Member member) {
+ return MemberAddr.builder()
+ .addr1("서울특별시")
+ .addr2("강남구")
+ .addr3("역삼동")
+ .streetAddr("테헤란로 123")
+ .buildingName("ㅁㅁ빌딩")
+ .latitude(37.5)
+ .longitude(127.0)
+ .isMain(true)
+ .member(member)
+ .build();
+ }
+
+ /**
+ * 임의 테스트 주소
+ * 비대표주소
+ * - 서울특별시 서초구 서초동 서초대로 456 / ㅇㅇ빌딩 (37.4, 127.1)
+ */
+ public static MemberAddr createSubAddr(Member member) {
+ return MemberAddr.builder()
+ .addr1("서울특별시")
+ .addr2("서초구")
+ .addr3("서초동")
+ .streetAddr("서초대로 456")
+ .buildingName("ㅇㅇ빌딩")
+ .latitude(37.4)
+ .longitude(127.1)
+ .isMain(false)
+ .member(member)
+ .build();
+ }
+
+ /**
+ * 커스텀주소
+ */
+ public static MemberAddr createAddr(Member member, String addr3, String streetAddr, boolean isMain) {
+ return MemberAddr.builder()
+ .addr1("경기도")
+ .addr2("안산시")
+ .addr3(addr3)
+ .streetAddr(streetAddr)
+ .buildingName("빌딩" + addr3)
+ .latitude(37.5)
+ .longitude(127.0)
+ .isMain(isMain)
+ .member(member)
+ .build();
+ }
+
+ /**
+ * 서울특별시 강남구 역삼동 (AddMemberNewAddr requestDto 기본값과 동일 - 중복 검증 등에 활용)
+ * - 서울특별시 강남구 역삼동 테헤란로 123 / 테스트빌딩 (37.5, 127.0)
+ */
+ public static MemberAddr createSeoulAddr(Member member, boolean isMain) {
+ return MemberAddr.builder()
+ .addr1("서울특별시")
+ .addr2("강남구")
+ .addr3("역삼동")
+ .streetAddr("테헤란로 123")
+ .buildingName("테스트빌딩")
+ .latitude(37.5)
+ .longitude(127.0)
+ .isMain(isMain)
+ .member(member)
+ .build();
+ }
+
+ /**
+ * 부산광역시 해운대구 좌동 (대표주소 해제 등 기존 주소 대체용)
+ * - 부산광역시 해운대구 좌동 해운대로 123 / 해운대빌딩 (35.1, 129.1)
+ */
+ public static MemberAddr createBusanAddr(Member member, boolean isMain) {
+ return MemberAddr.builder()
+ .addr1("부산광역시")
+ .addr2("해운대구")
+ .addr3("좌동")
+ .streetAddr("해운대로 123")
+ .buildingName("해운대빌딩")
+ .latitude(35.1)
+ .longitude(129.1)
+ .isMain(isMain)
+ .member(member)
+ .build();
+ }
+
+ /**
+ * 주소 5개 초과 테스트용 - index로 구분되는 고유 주소 생성
+ */
+ public static MemberAddr createAddrWithIndex(Member member, int index, boolean isMain) {
+ return MemberAddr.builder()
+ .addr1("서울특별시")
+ .addr2("구" + index)
+ .addr3("동" + index)
+ .streetAddr("도로" + index)
+ .buildingName("빌딩" + index)
+ .latitude(37.5 + index)
+ .longitude(127.0 + index)
+ .isMain(isMain)
+ .member(member)
+ .build();
+ }
+}
diff --git a/src/test/java/umc/cockple/demo/support/fixture/MemberFixture.java b/src/test/java/umc/cockple/demo/support/fixture/MemberFixture.java
index eea7b8dee..96d1f147a 100644
--- a/src/test/java/umc/cockple/demo/support/fixture/MemberFixture.java
+++ b/src/test/java/umc/cockple/demo/support/fixture/MemberFixture.java
@@ -12,6 +12,7 @@
import umc.cockple.demo.global.enums.Level;
import umc.cockple.demo.global.enums.Role;
+import java.time.LocalDate;
import java.time.LocalDateTime;
public class MemberFixture {
@@ -27,6 +28,18 @@ public static Member createMember(String nickname, Gender gender, Level level, L
.build();
}
+ public static Member createMember(String nickname, Gender gender, Level level, Long socialId, LocalDate birth) {
+ return Member.builder()
+ .memberName(nickname)
+ .nickname(nickname)
+ .gender(gender)
+ .level(level)
+ .birth(birth)
+ .isActive(MemberStatus.ACTIVE)
+ .socialId(socialId)
+ .build();
+ }
+
public static Member createMemberWithName(String memberName, String nickname, Gender gender, Level level, Long socialId) {
return Member.builder()
.memberName(memberName)
@@ -66,4 +79,12 @@ public static MemberExercise createMemberExercise(Member member, Exercise exerci
.exerciseMemberShipStatus(ExerciseMemberShipStatus.PARTY_MEMBER)
.build();
}
+
+ public static MemberExercise createExternalMemberExercise(Member member, Exercise exercise) {
+ return MemberExercise.builder()
+ .member(member)
+ .exercise(exercise)
+ .exerciseMemberShipStatus(ExerciseMemberShipStatus.EXTERNAL_PARTICIPANT)
+ .build();
+ }
}
diff --git a/src/test/resources/application-integrationtest.yml b/src/test/resources/application-integrationtest.yml
index 171e20b30..4236bc3f9 100644
--- a/src/test/resources/application-integrationtest.yml
+++ b/src/test/resources/application-integrationtest.yml
@@ -5,9 +5,16 @@ spring:
host: localhost
port: 6379
+ flyway:
+ enabled: true
+ locations: classpath:db/migration
+ encoding: UTF-8
+ baseline-on-migrate: false
+ out-of-order: false
+
jpa:
hibernate:
- ddl-auto: create
+ ddl-auto: none
properties:
hibernate:
dialect: org.hibernate.dialect.MySQL8Dialect
diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml
index 0507865ab..570edddd1 100644
--- a/src/test/resources/application.yml
+++ b/src/test/resources/application.yml
@@ -6,6 +6,9 @@ spring:
username: sa
password:
+ flyway:
+ enabled: false
+
sql:
init:
mode: never
diff --git a/src/test/resources/mysql-conf/timezone.cnf b/src/test/resources/mysql-conf/timezone.cnf
new file mode 100644
index 000000000..577235cfe
--- /dev/null
+++ b/src/test/resources/mysql-conf/timezone.cnf
@@ -0,0 +1,2 @@
+[mysqld]
+default-time-zone = '+09:00'
diff --git a/terraform/AGENTS.md b/terraform/AGENTS.md
new file mode 100644
index 000000000..c2e8d7bbf
--- /dev/null
+++ b/terraform/AGENTS.md
@@ -0,0 +1,31 @@
+# TERRAFORM GUIDE
+
+Apply root `AGENTS.md` first. This file only applies to `terraform/*`.
+
+## OVERVIEW
+Terraform provisions the Cockple GCP network/compute/storage stack and Cloudflare DNS integration.
+
+## WHERE TO LOOK
+| Task | Location | Notes |
+|------|----------|-------|
+| Providers | `main.tf` | Google + Cloudflare providers |
+| Network/firewall | `network.tf` | VPC, subnet, Cloudflare-only HTTP ingress, open SSH |
+| Compute bootstrap | `compute.tf` | VM, static IP, Docker install via startup script |
+| Storage/IAM | `storage.tf` | GCS bucket + service account + public read |
+| Inputs/outputs | `variables.tf`, `outputs.tf` | secret vars and exported IP/bucket/account |
+
+## CONVENTIONS
+- Region defaults to `asia-northeast3`.
+- HTTP ingress is intentionally restricted to Cloudflare IP ranges.
+- Bucket CORS is pinned to Cockple prod/staging domains.
+
+## ANTI-PATTERNS
+- Do not weaken firewall or bucket exposure without touching the matching runtime assumptions.
+- Do not move secrets out of sensitive variables into plain values.
+- Do not duplicate Docker/bootstrap logic here and in scripts without keeping them aligned.
+
+## COMMANDS
+```bash
+terraform plan
+terraform apply
+```