diff --git a/.github/workflows/pr-coverage.yml b/.github/workflows/pr-coverage.yml new file mode 100644 index 000000000..2989d0882 --- /dev/null +++ b/.github/workflows/pr-coverage.yml @@ -0,0 +1,272 @@ +name: PR Coverage Check + +on: + pull_request_target: + paths: + - 'src/main/java/**/*.java' + - 'src/test/java/**/*.java' + - 'build.gradle' + +jobs: + coverage: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + + steps: + - name: Checkout PR code + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Cache Gradle packages + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: gradle-${{ runner.os }}- + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Get changed Java files + id: changed-files + run: | + set -euo pipefail + + CHANGED_MAIN_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep '^src/main/java/.*\.java$' || true) + CHANGED_MAIN_CLASSES=$(printf '%s\n' "$CHANGED_MAIN_FILES" | sed '/^$/d' | sed 's|src/main/java/||' | sed 's|/|.|g' | sed 's|\.java$||') + + echo "Changed main Java files (package format):" + echo "$CHANGED_MAIN_CLASSES" + + { + echo 'CHANGED_FILES<> "$GITHUB_ENV" + + FILE_COUNT=$(printf '%s\n' "$CHANGED_MAIN_CLASSES" | sed '/^$/d' | wc -l | tr -d ' ') + echo "FILE_COUNT=$FILE_COUNT" >> "$GITHUB_ENV" + echo "Changed file count: $FILE_COUNT" + + - name: Run tests with JaCoCo + id: run-tests + run: | + ./gradlew test jacocoTestReport --no-daemon --build-cache + + - name: Parse coverage for changed files + id: parse-coverage + if: always() + env: + CHANGED_FILES_OUTCOME: ${{ steps.changed-files.outcome }} + RUN_TESTS_OUTCOME: ${{ steps.run-tests.outcome }} + run: | + python3 << 'PYEOF' + import os + import re + import xml.etree.ElementTree as ET + + def write_outputs(overall_coverage, total_lines, covered_lines, body_type, coverage_table): + with open(os.getenv('GITHUB_OUTPUT'), 'a') as f: + f.write(f"overall_coverage={overall_coverage}\n") + f.write(f"total_lines={total_lines}\n") + f.write(f"covered_lines={covered_lines}\n") + f.write(f"body_type={body_type}\n") + f.write("coverage_table< 0 else 0 + + table_lines = [ + '| Class | Coverage | Lines | Status |', + '|-------|----------|-------|--------|', + ] + for result in results: + status = '✅' if result['ratio'] >= 70 else ('⚠️' if result['ratio'] >= 50 else '❌') + class_name = result['class'].split('.')[-1] + pkg = '.'.join(result['class'].split('.')[:-1]) + table_lines.append( + f"| {class_name}
{pkg} | **{result['ratio']:.1f}%** | {result['covered']}/{result['total']} | {status} |" + ) + + write_outputs(f"{overall_ratio:.1f}", total_lines, overall_covered, 'coverage', '\n'.join(table_lines)) + PYEOF + + - name: Fail if coverage below threshold + if: steps.parse-coverage.outputs.body_type == 'coverage' + env: + OVERALL_COVERAGE: ${{ steps.parse-coverage.outputs.overall_coverage }} + run: | + COVERAGE_VAL=$(echo "$OVERALL_COVERAGE" | cut -d'.' -f1) + if [ "$COVERAGE_VAL" -lt 50 ]; then + echo "❌ 커버리지 ${OVERALL_COVERAGE}% 가 50% 임계값 미만입니다." + exit 1 + fi + echo "✅ 커버리지 ${OVERALL_COVERAGE}% 가 임계값을 충족합니다." + + - name: Comment PR with coverage results + if: always() + uses: actions/github-script@v7 + env: + OVERALL_COVERAGE: ${{ steps.parse-coverage.outputs.overall_coverage }} + TOTAL_LINES: ${{ steps.parse-coverage.outputs.total_lines }} + COVERED_LINES: ${{ steps.parse-coverage.outputs.covered_lines }} + BODY_TYPE: ${{ steps.parse-coverage.outputs.body_type }} + COVERAGE_TABLE: ${{ steps.parse-coverage.outputs.coverage_table }} + FILE_COUNT: ${{ env.FILE_COUNT }} + with: + script: | + const coverage = process.env.OVERALL_COVERAGE; + const totalLines = process.env.TOTAL_LINES; + const coveredLines = process.env.COVERED_LINES; + const bodyType = process.env.BODY_TYPE; + const table = process.env.COVERAGE_TABLE || ''; + const fileCount = process.env.FILE_COUNT || '0'; + + let body = '## 🧪 JaCoCo Coverage Report (Changed Files)\n\n'; + + if (bodyType === 'no-main-changes') { + body += '> 이 PR에서 변경된 main Java 소스 파일이 없습니다.\n'; + } else if (bodyType === 'no-coverage-data') { + body += '> 변경된 main Java 소스 파일에 대한 JaCoCo 데이터가 없습니다.\n'; + } else if (bodyType === 'workflow-error') { + body += '> 워크플로우 실행 중 오류가 발생했습니다. 로그를 확인해주세요.\n'; + } else if (bodyType === 'no-report') { + body += '> JaCoCo 리포트 파일이 생성되지 않았습니다. 테스트 실행을 확인해주세요.\n'; + } else if (!bodyType) { + body += '> 이전 스텝에서 오류가 발생해 커버리지를 계산하지 못했습니다.\n'; + } else { + const ratio = parseFloat(coverage); + const status = ratio >= 70 ? '✅' : (ratio >= 50 ? '⚠️' : '❌'); + + body += `### Summary\n`; + body += `- **Overall Coverage:** ${coverage}% ${status}\n`; + body += `- **Covered Lines:** ${coveredLines} / ${totalLines}\n`; + body += `- **Changed Files:** ${fileCount}\n\n`; + body += `### Coverage by File\n`; + body += `${table}\n\n`; + + if (ratio < 50) { + body += '> ❌ **알림:** 커버리지가 50% 미만입니다. 테스트를 추가해주세요.\n'; + } else if (ratio < 70) { + body += '> ⚠️ **알림:** 커버리지가 70% 미만입니다. 테스트 추가를 권장합니다.\n'; + } + } + + body += `\n[📊 View Workflow Run](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})\n`; + + const comments = await github.paginate( + github.rest.issues.listComments, + { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + } + ); + + const existingComment = comments.find(c => + c.user.login === 'github-actions[bot]' && + c.body.includes('JaCoCo Coverage Report') + ); + + if (existingComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existingComment.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } + + - name: Upload JaCoCo report + if: always() + uses: actions/upload-artifact@v4 + with: + name: jacoco-report + path: build/reports/jacoco/test/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index 5f7c49772..83f128d4e 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,4 @@ mcp-bridge/node_modules/ mcp-bridge/.env **/google-service-account.json +.omx/ diff --git a/Dockerfile b/Dockerfile index 1cd1cd4a9..a3f1c8d10 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,29 @@ FROM amazoncorretto:21-alpine -ARG OTEL_JAVA_AGENT_VERSION=2.18.1 - WORKDIR /app -RUN addgroup -S konect && adduser -S konect -G konect - -COPY build/libs/KONECT_API.jar KONECT_API.jar -COPY opentelemetry-javaagent.jar opentelemetry-javaagent.jar +RUN addgroup -g 1000 -S konect \ + && adduser -u 1000 -S konect -G konect \ + && mkdir -p /app /app/logs \ + && chown -R 1000:1000 /app \ + && chmod 755 /app /app/logs -RUN chown -R konect:konect /app +COPY --chown=1000:1000 build/libs/KONECT_API.jar KONECT_API.jar +COPY --chown=1000:1000 opentelemetry-javaagent.jar opentelemetry-javaagent.jar -USER konect:konect +USER 1000:1000 EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://localhost:8080/actuator/health || exit 1 -ENTRYPOINT ["java", "-jar", "KONECT_API.jar"] +ENTRYPOINT ["java", \ + "-Xms128m", \ + "-Xmx256m", \ + "-XX:MaxMetaspaceSize=256m", \ + "-XX:+UseStringDeduplication", \ + "-Xlog:gc*:file=/app/logs/gc.log:time,uptime,level,tags:filecount=5,filesize=10m", \ + "-XX:+HeapDumpOnOutOfMemoryError", \ + "-XX:HeapDumpPath=/app/logs/heapdump.hprof", \ + "-jar", "KONECT_API.jar"] \ No newline at end of file diff --git a/build.gradle b/build.gradle index 1b34a555c..ed42fe26e 100644 --- a/build.gradle +++ b/build.gradle @@ -3,6 +3,7 @@ plugins { id 'org.springframework.boot' version '3.5.8' id 'io.spring.dependency-management' version '1.1.7' id 'checkstyle' + id 'jacoco' } group = 'gg.agit' @@ -85,6 +86,7 @@ dependencies { testImplementation 'org.testcontainers:testcontainers' testImplementation 'org.testcontainers:junit-jupiter' testImplementation 'org.testcontainers:mysql' + testImplementation 'com.github.codemonstur:embedded-redis:1.4.3' testRuntimeOnly 'com.h2database:h2' testCompileOnly 'org.projectlombok:lombok' testAnnotationProcessor 'org.projectlombok:lombok' @@ -106,6 +108,47 @@ tasks.named('test') { useJUnitPlatform() maxParallelForks = Math.min(Runtime.runtime.availableProcessors(), 4) + reports { + // CI는 JUnit XML만으로 충분하므로 HTML 리포트 생성 비용은 줄인다. + html.required = !System.getenv().containsKey('CI') + } +} + +// JaCoCo 커버리지 리포트 설정 +jacocoTestReport { + dependsOn test + + reports { + xml.required = true + html.required = true + } + + // 커버리지에서 제외할 클래스 설정 + afterEvaluate { + classDirectories.setFrom( + fileTree(layout.buildDirectory.dir("classes/java/main")) { + exclude([ + // DTO (dto 패키지 내부만 제외) + "**/dto/*.class", + // Entity + "**/domain/**/model/*.class", + "**/model/*.class", + // Configuration + "**/config/*.class", + "**/*Config.class", + // Exception + "**/exception/*.class", + "**/*Exception.class", + // 기타 + "**/Application.class", // Spring Boot 메인 클래스 + ]) + } + ) + } +} + +tasks.named('check') { + dependsOn jacocoTestReport } checkstyle { diff --git a/lombok.config b/lombok.config new file mode 100644 index 000000000..cdaf3ee2b --- /dev/null +++ b/lombok.config @@ -0,0 +1,3 @@ +# JaCoCo에서 롬복 생성 코드 제외 +config.stopBubbling = true +lombok.addLombokGeneratedAnnotation = true diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 000000000..ec5db745d --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "skills": { + "gh-address-comments": { + "source": "smithery.ai", + "sourceType": "well-known", + "computedHash": "39acf09da5896afde2b61b22f4354a72dbed6633da9e8a578eacec4899b0ecfb" + } + } +} diff --git a/src/main/java/gg/agit/konect/admin/advertisement/controller/AdminAdvertisementApi.java b/src/main/java/gg/agit/konect/admin/advertisement/controller/AdminAdvertisementApi.java index 229e26d36..325556b5a 100644 --- a/src/main/java/gg/agit/konect/admin/advertisement/controller/AdminAdvertisementApi.java +++ b/src/main/java/gg/agit/konect/admin/advertisement/controller/AdminAdvertisementApi.java @@ -13,15 +13,12 @@ import gg.agit.konect.admin.advertisement.dto.AdminAdvertisementResponse; import gg.agit.konect.admin.advertisement.dto.AdminAdvertisementUpdateRequest; import gg.agit.konect.admin.advertisement.dto.AdminAdvertisementsResponse; -import gg.agit.konect.domain.user.enums.UserRole; -import gg.agit.konect.global.auth.annotation.Auth; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @Tag(name = "(Admin) Advertisement: 광고", description = "어드민 광고 API") @RequestMapping("/admin/advertisements") -@Auth(roles = {UserRole.ADMIN}) public interface AdminAdvertisementApi { @Operation(summary = "광고 목록을 조회한다.") diff --git a/src/main/java/gg/agit/konect/admin/advertisement/controller/AdminAdvertisementController.java b/src/main/java/gg/agit/konect/admin/advertisement/controller/AdminAdvertisementController.java index 85ba7cb6c..cde3cd1c9 100644 --- a/src/main/java/gg/agit/konect/admin/advertisement/controller/AdminAdvertisementController.java +++ b/src/main/java/gg/agit/konect/admin/advertisement/controller/AdminAdvertisementController.java @@ -11,10 +11,13 @@ import gg.agit.konect.admin.advertisement.dto.AdminAdvertisementUpdateRequest; import gg.agit.konect.admin.advertisement.dto.AdminAdvertisementsResponse; import gg.agit.konect.admin.advertisement.service.AdminAdvertisementService; +import gg.agit.konect.domain.user.enums.UserRole; +import gg.agit.konect.global.auth.annotation.Auth; import jakarta.validation.Valid; @RestController @Validated +@Auth(roles = {UserRole.ADMIN}) public class AdminAdvertisementController implements AdminAdvertisementApi { private final AdminAdvertisementService adminAdvertisementService; diff --git a/src/main/java/gg/agit/konect/admin/schedule/controller/AdminScheduleApi.java b/src/main/java/gg/agit/konect/admin/schedule/controller/AdminScheduleApi.java index 7fbff8e6e..44cc1e990 100644 --- a/src/main/java/gg/agit/konect/admin/schedule/controller/AdminScheduleApi.java +++ b/src/main/java/gg/agit/konect/admin/schedule/controller/AdminScheduleApi.java @@ -10,8 +10,6 @@ import gg.agit.konect.admin.schedule.dto.AdminScheduleCreateRequest; import gg.agit.konect.admin.schedule.dto.AdminScheduleUpsertRequest; -import gg.agit.konect.domain.user.enums.UserRole; -import gg.agit.konect.global.auth.annotation.Auth; import gg.agit.konect.global.auth.annotation.UserId; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -19,7 +17,6 @@ @Tag(name = "(Admin) Schedule: 일정", description = "어드민 일정 API") @RequestMapping("/admin/schedules") -@Auth(roles = {UserRole.ADMIN}) public interface AdminScheduleApi { @Operation(summary = "일정을 생성한다.", description = """ @@ -37,7 +34,7 @@ ResponseEntity createSchedule( @Operation(summary = "일정을 일괄 생성/수정한다.", description = """ scheduleId가 없으면 신규 생성, 있으면 해당 일정 수정입니다. - + **scheduleType (일정 구분):** - `UNIVERSITY`: 대학교 일정 - `CLUB`: 동아리 일정 diff --git a/src/main/java/gg/agit/konect/admin/schedule/controller/AdminScheduleController.java b/src/main/java/gg/agit/konect/admin/schedule/controller/AdminScheduleController.java index 6c9269a7e..2804758d2 100644 --- a/src/main/java/gg/agit/konect/admin/schedule/controller/AdminScheduleController.java +++ b/src/main/java/gg/agit/konect/admin/schedule/controller/AdminScheduleController.java @@ -7,11 +7,14 @@ import gg.agit.konect.admin.schedule.dto.AdminScheduleCreateRequest; import gg.agit.konect.admin.schedule.dto.AdminScheduleUpsertRequest; import gg.agit.konect.admin.schedule.service.AdminScheduleService; +import gg.agit.konect.domain.user.enums.UserRole; +import gg.agit.konect.global.auth.annotation.Auth; import lombok.RequiredArgsConstructor; @RestController @RequiredArgsConstructor @RequestMapping("/admin/schedules") +@Auth(roles = {UserRole.ADMIN}) public class AdminScheduleController implements AdminScheduleApi { private final AdminScheduleService adminScheduleService; diff --git a/src/main/java/gg/agit/konect/admin/version/controller/AdminVersionApi.java b/src/main/java/gg/agit/konect/admin/version/controller/AdminVersionApi.java index e671a6061..2101b03f7 100644 --- a/src/main/java/gg/agit/konect/admin/version/controller/AdminVersionApi.java +++ b/src/main/java/gg/agit/konect/admin/version/controller/AdminVersionApi.java @@ -6,15 +6,12 @@ import org.springframework.web.bind.annotation.RequestMapping; import gg.agit.konect.admin.version.dto.AdminVersionCreateRequest; -import gg.agit.konect.domain.user.enums.UserRole; -import gg.agit.konect.global.auth.annotation.Auth; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @Tag(name = "(Admin) Version: 버전", description = "어드민 버전 API") @RequestMapping("/admin/versions") -@Auth(roles = {UserRole.ADMIN}) public interface AdminVersionApi { @Operation(summary = "새 버전을 등록한다.") diff --git a/src/main/java/gg/agit/konect/admin/version/controller/AdminVersionController.java b/src/main/java/gg/agit/konect/admin/version/controller/AdminVersionController.java index 845b94a18..34918dcdd 100644 --- a/src/main/java/gg/agit/konect/admin/version/controller/AdminVersionController.java +++ b/src/main/java/gg/agit/konect/admin/version/controller/AdminVersionController.java @@ -7,12 +7,15 @@ import gg.agit.konect.admin.version.dto.AdminVersionCreateRequest; import gg.agit.konect.admin.version.service.AdminVersionService; +import gg.agit.konect.domain.user.enums.UserRole; +import gg.agit.konect.global.auth.annotation.Auth; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @RestController @RequiredArgsConstructor @RequestMapping("/admin/versions") +@Auth(roles = {UserRole.ADMIN}) public class AdminVersionController implements AdminVersionApi { private final AdminVersionService adminVersionService; diff --git a/src/main/java/gg/agit/konect/domain/chat/controller/ChatApi.java b/src/main/java/gg/agit/konect/domain/chat/controller/ChatApi.java index 3a3e74267..b42c87be9 100644 --- a/src/main/java/gg/agit/konect/domain/chat/controller/ChatApi.java +++ b/src/main/java/gg/agit/konect/domain/chat/controller/ChatApi.java @@ -139,9 +139,13 @@ ResponseEntity getInvitableUsers( - 채팅방 참여자만 메시지를 조회할 수 있습니다. - 일반 유저는 자신이 참여한 채팅방만 조회할 수 있습니다. - 어드민은 모든 어드민 채팅방을 조회할 수 있습니다. + - `messageId`가 제공되면 해당 메시지가 포함된 페이지를 자동으로 계산하여 반환합니다. + 검색 결과에서 특정 메시지 위치로 이동할 때 사용합니다. ## 에러 - - FORBIDDEN_CHAT_ROOM_ACCESS (403): 채팅방에 접근할 권한이 없습니다. + - FORBIDDEN_CHAT_ROOM_ACCESS (403): 채팅방에 접근할 권한이 없습니다 (messageId가 없는 일반 조회 시). + - NOT_FOUND_CHAT_ROOM (404): 채팅방을 찾을 수 없습니다. + messageId가 제공된 경우, 메시지가 유효하지 않거나 접근 권한이 없거나 가시성 경계를 벗어난 경우에도 모두 404로 통일 응답됩니다. """) @GetMapping("/rooms/{chatRoomId}") ResponseEntity getChatRoomMessages( @@ -150,7 +154,8 @@ ResponseEntity getChatRoomMessages( @Min(value = 1, message = "페이지 당 항목 수는 1 이상이어야 합니다.") @RequestParam(name = "limit", defaultValue = "20", required = false) Integer limit, @PathVariable(value = "chatRoomId") Integer chatRoomId, - @UserId Integer userId + @UserId Integer userId, + @RequestParam(name = "messageId", required = false) Integer messageId ); @Operation(summary = "메시지를 전송한다.", description = """ diff --git a/src/main/java/gg/agit/konect/domain/chat/controller/ChatController.java b/src/main/java/gg/agit/konect/domain/chat/controller/ChatController.java index 4fe35f06e..844a25f53 100644 --- a/src/main/java/gg/agit/konect/domain/chat/controller/ChatController.java +++ b/src/main/java/gg/agit/konect/domain/chat/controller/ChatController.java @@ -85,9 +85,10 @@ public ResponseEntity getChatRoomMessages( @RequestParam(name = "page", defaultValue = "1") Integer page, @RequestParam(name = "limit", defaultValue = "20", required = false) Integer limit, @PathVariable(value = "chatRoomId") Integer chatRoomId, - @UserId Integer userId + @UserId Integer userId, + @RequestParam(name = "messageId", required = false) Integer messageId ) { - ChatMessagePageResponse response = chatService.getMessages(userId, chatRoomId, page, limit); + ChatMessagePageResponse response = chatService.getMessages(userId, chatRoomId, page, limit, messageId); return ResponseEntity.ok(response); } diff --git a/src/main/java/gg/agit/konect/domain/chat/dto/ChatMessageMatchResult.java b/src/main/java/gg/agit/konect/domain/chat/dto/ChatMessageMatchResult.java index 91a56d4af..1c68031c9 100644 --- a/src/main/java/gg/agit/konect/domain/chat/dto/ChatMessageMatchResult.java +++ b/src/main/java/gg/agit/konect/domain/chat/dto/ChatMessageMatchResult.java @@ -29,7 +29,10 @@ public record ChatMessageMatchResult( @Schema(description = "매칭된 메시지 전송 시간", example = "2025.12.19 23:21", requiredMode = REQUIRED) @JsonFormat(pattern = "yyyy.MM.dd HH:mm") - LocalDateTime matchedMessageSentAt + LocalDateTime matchedMessageSentAt, + + @Schema(description = "검색에 매칭된 메시지 ID", example = "42", requiredMode = REQUIRED) + Integer matchedMessageId ) { public static ChatMessageMatchResult from(ChatRoomSummaryResponse room, ChatMessage message) { @@ -39,7 +42,8 @@ public static ChatMessageMatchResult from(ChatRoomSummaryResponse room, ChatMess room.roomName(), room.roomImageUrl(), message.getContent(), - message.getCreatedAt() + message.getCreatedAt(), + message.getId() ); } } diff --git a/src/main/java/gg/agit/konect/domain/chat/model/ChatRoomMember.java b/src/main/java/gg/agit/konect/domain/chat/model/ChatRoomMember.java index 7e9b39ffc..e56a741a1 100644 --- a/src/main/java/gg/agit/konect/domain/chat/model/ChatRoomMember.java +++ b/src/main/java/gg/agit/konect/domain/chat/model/ChatRoomMember.java @@ -24,6 +24,8 @@ @NoArgsConstructor(access = PROTECTED) public class ChatRoomMember extends BaseEntity { + private static final long DB_TIMESTAMP_PRECISION_NANOS = 1_000L; + @EmbeddedId private ChatRoomMemberId id; @@ -139,6 +141,20 @@ public void restoreDirectRoom() { this.leftAt = null; } + /** + * 상대방의 새 메시지로 채팅방이 다시 보여야 할 때 사용한다. + *

+ * 현재 구현은 메시지 노출/안읽음 계산을 모두 {@code > 기준시각}으로 비교한다. + * 그래서 첫 새 메시지와 같은 시각을 경계로 저장하면 그 메시지가 숨겨질 수 있어, + * DB timestamp(6) 정밀도에서 보정값이 사라지지 않도록 경계를 1마이크로초 앞당겨 해당 메시지부터 보이고 안읽음으로 계산되게 한다. + */ + public void restoreDirectRoomFromIncomingMessage(LocalDateTime messageCreatedAt) { + LocalDateTime visibleFrom = messageCreatedAt.minusNanos(DB_TIMESTAMP_PRECISION_NANOS); + this.leftAt = null; + this.visibleMessageFrom = visibleFrom; + this.lastReadAt = visibleFrom; + } + /** * 사용자가 채팅방을 다시 열어 새 대화를 시작할 때 사용한다. *

diff --git a/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageRepository.java b/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageRepository.java index 82551da83..921513d14 100644 --- a/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageRepository.java +++ b/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageRepository.java @@ -2,6 +2,7 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -35,13 +36,15 @@ List countUnreadMessagesByChatRoomIdsAndUserId( @Param("receiverId") Integer receiverId ); + // ORDER BY는 countNewerMessagesByChatRoomId의 WHERE 조건과 + // 일치해야 함. 페이지 계산 정확도가 두 쿼리의 정렬 일관성에 의존함. @Query(""" SELECT cm FROM ChatMessage cm JOIN FETCH cm.sender WHERE cm.chatRoom.id = :chatRoomId AND (:visibleMessageFrom IS NULL OR cm.createdAt > :visibleMessageFrom) - ORDER BY cm.createdAt DESC + ORDER BY cm.createdAt DESC, cm.id DESC """) Page findByChatRoomId( @Param("chatRoomId") Integer chatRoomId, @@ -140,6 +143,25 @@ List searchLatestMatchingMessagesByChatRoomIds( @Param("keyword") String keyword ); + @Query("SELECT cm FROM ChatMessage cm JOIN FETCH cm.chatRoom WHERE cm.id = :messageId") + Optional findByIdWithChatRoom(@Param("messageId") Integer messageId); + + // ORDER BY 기준이 findByChatRoomId와 일치해야 함 (createdAt DESC, id DESC). + // 페이지 계산 정확도가 두 쿼리의 정렬 일관성에 의존함. + @Query(""" + SELECT COUNT(m) + FROM ChatMessage m + WHERE m.chatRoom.id = :chatRoomId + AND (m.createdAt > :createdAt OR (m.createdAt = :createdAt AND m.id > :messageId)) + AND (:visibleMessageFrom IS NULL OR m.createdAt > :visibleMessageFrom) + """) + long countNewerMessagesByChatRoomId( + @Param("chatRoomId") Integer chatRoomId, + @Param("messageId") Integer messageId, + @Param("createdAt") LocalDateTime createdAt, + @Param("visibleMessageFrom") LocalDateTime visibleMessageFrom + ); + @Query(""" SELECT COUNT(m) FROM ChatMessage m diff --git a/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java b/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java index 7d6ec6363..a3a064f5b 100644 --- a/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java +++ b/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java @@ -170,13 +170,23 @@ List findAllSystemAdminDirectRooms( FROM ChatRoom cr JOIN ChatRoomMember crm ON crm.id.chatRoomId = cr.id JOIN User u ON u.id = crm.id.userId - JOIN ChatRoomMember adminCrm ON adminCrm.id.chatRoomId = cr.id - AND adminCrm.id.userId = :systemAdminId + JOIN ChatRoomMember systemAdminCrm ON systemAdminCrm.id.chatRoomId = cr.id + AND systemAdminCrm.id.userId = :systemAdminId + LEFT JOIN ChatRoomMember viewerAdminCrm ON viewerAdminCrm.id.chatRoomId = cr.id + AND viewerAdminCrm.id.userId = :viewerAdminId LEFT JOIN ChatMessage cm ON cm.chatRoom.id = cr.id AND cm.sender.id <> :systemAdminId - AND cm.createdAt > adminCrm.lastReadAt + AND cm.createdAt > systemAdminCrm.lastReadAt WHERE cr.roomType = :roomType AND u.role != :adminRole + AND ( + viewerAdminCrm.leftAt IS NULL + OR viewerAdminCrm.id.userId IS NULL + OR ( + viewerAdminCrm.leftAt IS NOT NULL + AND cr.lastMessageSentAt > viewerAdminCrm.visibleMessageFrom + ) + ) AND EXISTS ( SELECT 1 FROM ChatMessage userReply JOIN userReply.sender userSender @@ -188,6 +198,7 @@ ORDER BY COALESCE(cr.lastMessageSentAt, cr.createdAt) DESC """) List findAdminChatRoomsOptimized( @Param("systemAdminId") Integer systemAdminId, + @Param("viewerAdminId") Integer viewerAdminId, @Param("adminRole") UserRole adminRole, @Param("roomType") ChatType roomType ); diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.java index 3f285189e..3dd25d68c 100644 --- a/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.java +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.java @@ -4,12 +4,8 @@ import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_CHAT_ROOM; import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.Comparator; import java.util.List; -import java.util.Map; import java.util.Objects; -import java.util.stream.Collectors; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.dao.DuplicateKeyException; @@ -24,7 +20,6 @@ import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.club.model.ClubMember; import gg.agit.konect.domain.club.repository.ClubMemberRepository; -import gg.agit.konect.domain.user.enums.UserRole; import gg.agit.konect.domain.user.model.User; import gg.agit.konect.domain.user.repository.UserRepository; import gg.agit.konect.global.exception.CustomException; @@ -37,7 +32,7 @@ @Transactional(readOnly = true) public class ChatRoomMembershipService { - private static final int SYSTEM_ADMIN_ID = 1; + public static final int SYSTEM_ADMIN_ID = 1; private final ChatRoomRepository chatRoomRepository; private final ChatRoomMemberRepository chatRoomMemberRepository; @@ -64,79 +59,22 @@ public void removeClubMember(Integer clubId, Integer userId) { .ifPresent(room -> chatRoomMemberRepository.deleteByChatRoomIdAndUserId(room.getId(), userId)); } - @Transactional(propagation = Propagation.REQUIRES_NEW) - public void ensureClubRoomMemberships(Integer userId) { - List memberships = clubMemberRepository.findAllByUserId(userId); - if (memberships.isEmpty()) { - return; - } - - Map membershipByClubId = memberships.stream() - .collect(Collectors.toMap(cm -> cm.getClub().getId(), cm -> cm, (a, b) -> a)); - - List rooms = resolveOrCreateClubRooms(memberships).stream() - .sorted(Comparator.comparing(ChatRoom::getId)) - .toList(); - List roomIds = rooms.stream().map(ChatRoom::getId).toList(); - if (roomIds.isEmpty()) { - return; - } - - Map memberByRoomId = chatRoomMemberRepository - .findByChatRoomIdsAndUserId(roomIds, userId) - .stream() - .collect(Collectors.toMap(ChatRoomMember::getChatRoomId, member -> member, (a, b) -> a)); - - for (ChatRoom room : rooms) { - ClubMember member = membershipByClubId.get(room.getClub().getId()); - if (member == null) { - continue; - } - - ChatRoomMember existingMember = memberByRoomId.get(room.getId()); - if (existingMember != null) { - LocalDateTime lastReadAt = existingMember.getLastReadAt(); - if (lastReadAt == null || lastReadAt.isBefore(member.getCreatedAt())) { - chatRoomMemberRepository.updateLastReadAtIfOlder( - room.getId(), userId, member.getCreatedAt() - ); - } - continue; - } - - saveRoomMemberIgnoringDuplicate(room, member.getUser(), member.getCreatedAt()); - } - } - @Transactional(propagation = Propagation.REQUIRES_NEW) public void updateLastReadAt(Integer roomId, Integer userId, LocalDateTime readAt) { chatRoomMemberRepository.updateLastReadAtIfOlder(roomId, userId, readAt); } @Transactional(propagation = Propagation.REQUIRES_NEW) - public void updateDirectRoomLastReadAt(Integer roomId, Integer userId, LocalDateTime readAt) { - User user = userRepository.getById(userId); - ChatRoom room = chatRoomRepository.findById(roomId) - .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); + public void updateDirectRoomLastReadAt(Integer roomId, User user, LocalDateTime readAt, ChatRoom room) { + // 어드민이 SYSTEM_ADMIN 방의 메시지를 읽으면 SYSTEM_ADMIN의 lastReadAt을 업데이트 + if (user.isAdmin() && isSystemAdminRoom(roomId)) { + chatRoomMemberRepository.updateLastReadAtIfOlder(roomId, SYSTEM_ADMIN_ID, readAt); + return; + } ensureDirectRoomMemberExists(room, user, readAt); - if (user.getRole() == UserRole.ADMIN) { - List members = chatRoomMemberRepository.findByChatRoomId(roomId); - boolean isSystemAdmin = members.stream() - .anyMatch(member -> Objects.equals(member.getUserId(), SYSTEM_ADMIN_ID)); - - if (isSystemAdmin) { - for (ChatRoomMember member : members) { - if (member.getUser().getRole() == UserRole.ADMIN) { - chatRoomMemberRepository.updateLastReadAtIfOlder(roomId, member.getUserId(), readAt); - } - } - return; - } - } - - chatRoomMemberRepository.updateLastReadAtIfOlder(roomId, userId, readAt); + chatRoomMemberRepository.updateLastReadAtIfOlder(roomId, user.getId(), readAt); } @Transactional(propagation = Propagation.REQUIRES_NEW) @@ -166,39 +104,6 @@ private ChatRoom findOrCreateClubRoom(Club club) { }); } - private List resolveOrCreateClubRooms(List memberships) { - Map clubById = memberships.stream() - .map(ClubMember::getClub) - .collect(Collectors.toMap(Club::getId, club -> club, (a, b) -> a)); - - Map roomByClubId = chatRoomRepository.findByClubIds(new ArrayList<>(clubById.keySet())) - .stream() - .filter(room -> room.getClub() != null) - .collect(Collectors.toMap(room -> room.getClub().getId(), room -> room, (a, b) -> a)); - - for (Map.Entry clubEntry : clubById.entrySet()) { - if (roomByClubId.containsKey(clubEntry.getKey())) { - continue; - } - try { - ChatRoom createdRoom = chatRoomRepository.save(ChatRoom.clubGroupOf(clubEntry.getValue())); - roomByClubId.put(clubEntry.getKey(), createdRoom); - } catch (DataIntegrityViolationException e) { - if (!isDuplicateKeyException(e)) { - throw e; - } - log.debug("클럽 채팅방 동시 생성 감지, 재조회: clubId={}", clubEntry.getKey()); - chatRoomRepository.findByClubId(clubEntry.getKey()) - .ifPresent(room -> roomByClubId.put(clubEntry.getKey(), room)); - } - } - - return memberships.stream() - .map(membership -> roomByClubId.get(membership.getClub().getId())) - .filter(Objects::nonNull) - .toList(); - } - private void ensureMember(ChatRoom room, User user, LocalDateTime baseline) { chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId()) .ifPresentOrElse(member -> { @@ -226,8 +131,9 @@ private void ensureDirectRoomMemberExists(ChatRoom room, User user, LocalDateTim return; } - if (user.getRole() == UserRole.ADMIN && isSystemAdminRoom(room.getId())) { - saveRoomMemberIgnoringDuplicate(room, user, readAt); + // 어드민은 SYSTEM_ADMIN 방의 메시지를 조회할 수 있지만, 멤버로 추가되지는 않는다 + // (멤버가 추가되면 findByTwoUsers에서 해당 방을 찾지 못해 채팅방이 중복 생성됨) + if (user.isAdmin() && isSystemAdminRoom(room.getId())) { return; } diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java index 9b04c825f..1aa03cc44 100644 --- a/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java @@ -1,5 +1,6 @@ package gg.agit.konect.domain.chat.service; +import static gg.agit.konect.domain.chat.service.ChatRoomMembershipService.SYSTEM_ADMIN_ID; import static gg.agit.konect.global.code.ApiResponseCode.*; import java.time.LocalDateTime; @@ -19,7 +20,6 @@ import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; @@ -70,7 +70,6 @@ @Transactional(readOnly = true) public class ChatService { - private static final int SYSTEM_ADMIN_ID = 1; private static final String ETC_SECTION_NAME = "기타"; private static final String DEFAULT_GROUP_ROOM_NAME = "그룹 채팅"; @@ -95,7 +94,7 @@ public ChatRoomResponse createOrGetChatRoom(Integer currentUserId, ChatRoomCreat throw CustomException.of(CANNOT_CREATE_CHAT_ROOM_WITH_SELF); } - if (currentUser.getRole() == UserRole.ADMIN && targetUser.getRole() != UserRole.ADMIN) { + if (currentUser.isAdmin() && !targetUser.isAdmin()) { return getOrCreateSystemAdminChatRoomForUser(targetUser, currentUser); } @@ -209,8 +208,6 @@ public void kickMember(Integer requesterId, Integer roomId, Integer targetUserId } public ChatRoomsSummaryResponse getChatRooms(Integer userId) { - chatRoomMembershipService.ensureClubRoomMemberships(userId); - List directRooms = getDirectChatRooms(userId); List clubRooms = getClubChatRooms(userId); List groupRooms = getGroupChatRooms(userId); @@ -268,7 +265,7 @@ public ChatRoomsSummaryResponse getChatRooms(Integer userId) { ) ); - return new ChatRoomsSummaryResponse(getAccessibleChatRooms(userId).rooms()); + return new ChatRoomsSummaryResponse(rooms); } public ChatSearchResponse searchChats(Integer userId, String keyword, Integer page, Integer limit) { @@ -390,15 +387,35 @@ record SectionKey(Integer clubId, String clubName) { return ChatInvitableUsersResponse.forClubSort(pagedInvitableUsers, sections); } - @Transactional(propagation = Propagation.NOT_SUPPORTED) + @Transactional(readOnly = true) public ChatMessagePageResponse getMessages(Integer userId, Integer roomId, Integer page, Integer limit) { + return getMessages(userId, roomId, page, limit, null); + } + + @Transactional(readOnly = true) + public ChatMessagePageResponse getMessages( + Integer userId, Integer roomId, Integer page, Integer limit, Integer messageId + ) { ChatRoom room = chatRoomRepository.findById(roomId) .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); + User user = userRepository.getById(userId); + + if (messageId != null) { + ensureMessageLookupAccess(room, user, userId); + page = resolvePageForMessage(roomId, messageId, room, user, limit); + } LocalDateTime readAt = LocalDateTime.now(); if (room.isDirectRoom()) { - chatRoomMembershipService.updateDirectRoomLastReadAt(roomId, userId, readAt); + boolean isAdminViewingSystemRoom = user.isAdmin() && isSystemAdminRoom(room); + if (isAdminViewingSystemRoom) { + chatRoomMembershipService.updateLastReadAt(roomId, SYSTEM_ADMIN_ID, readAt); + recordPresenceSafely(roomId, userId); + return getAdminSystemDirectChatRoomMessages(user, room, roomId, page, limit, readAt); + } + + chatRoomMembershipService.updateDirectRoomLastReadAt(roomId, user, readAt, room); recordPresenceSafely(roomId, userId); return getDirectChatRoomMessages(userId, roomId, page, limit, readAt); } @@ -442,7 +459,12 @@ public ChatMuteResponse toggleMute(Integer userId, Integer roomId) { ClubMember member = clubMemberRepository.getByClubIdAndUserId(room.getClub().getId(), userId); ensureRoomMember(room, member.getUser(), member.getCreatedAt()); } else if (room.isDirectRoom()) { - getAccessibleDirectRoomMember(room, user); + // 어드민이 SYSTEM_ADMIN 방에 접근하는 경우는 멤버십 체크를 건너뜀 + boolean isAdminAccessingSystemAdminRoom = user.isAdmin() + && isSystemAdminRoom(room); + if (!isAdminAccessingSystemAdminRoom) { + getAccessibleDirectRoomMember(room, user); + } } else { getAccessibleRoomMember(room, userId); } @@ -481,8 +503,8 @@ public void updateChatRoomName(Integer userId, Integer roomId, ChatRoomNameUpdat private List getDirectChatRooms(Integer userId) { User user = userRepository.getById(userId); - if (user.getRole() == UserRole.ADMIN) { - return getAdminDirectChatRooms(); + if (user.isAdmin()) { + return getAdminDirectChatRooms(userId); } List roomSummaries = new ArrayList<>(); @@ -525,9 +547,9 @@ private List getDirectChatRooms(Integer userId) { return roomSummaries; } - private List getAdminDirectChatRooms() { + private List getAdminDirectChatRooms(Integer adminUserId) { List projections = chatRoomRepository.findAdminChatRoomsOptimized( - SYSTEM_ADMIN_ID, UserRole.ADMIN, ChatType.DIRECT + SYSTEM_ADMIN_ID, adminUserId, UserRole.ADMIN, ChatType.DIRECT ); return projections.stream() @@ -610,33 +632,27 @@ private List getGroupChatRooms(Integer userId) { .toList(); } - private ChatMessagePageResponse getDirectChatRoomMessages( - Integer userId, + private ChatMessagePageResponse buildDirectChatRoomMessages( + User user, Integer roomId, Integer page, Integer limit, - LocalDateTime readAt + LocalDateTime readAt, + LocalDateTime visibleMessageFrom, + List sortedReadBaselines, + Integer maskedAdminId ) { - ChatRoom chatRoom = getDirectRoom(roomId); - User user = userRepository.getById(userId); - ChatRoomMember member = getOrCreateDirectRoomMember(chatRoom, user); - LocalDateTime visibleMessageFrom = prepareDirectRoomAccess(member, chatRoom); - - boolean isAdminViewingSystemRoom = user.getRole() == UserRole.ADMIN && isSystemAdminRoom(chatRoom); - PageRequest pageable = PageRequest.of(page - 1, limit); Page messages = chatMessageRepository.findByChatRoomId(roomId, visibleMessageFrom, pageable); - List members = chatRoomMemberRepository.findByChatRoomId(roomId); - List sortedReadBaselines = isAdminViewingSystemRoom - ? toAdminChatReadBaselines(members) - : toSortedReadBaselines(members); - - Integer maskedAdminId = getMaskedAdminId(user, chatRoom); List responseMessages = messages.getContent().stream() .map(message -> { - Integer senderId = resolveDirectSenderId(message, maskedAdminId); - boolean isMine = shouldDisplayAsOwnMessage(user, message, isAdminViewingSystemRoom); + Integer senderId = maskedAdminId != null + ? resolveDirectSenderId(message, maskedAdminId) + : message.getSender().getId(); + boolean isMine = maskedAdminId != null + ? shouldDisplayAsOwnMessage(user, message, true) + : message.isSentBy(user.getId()); boolean isRead = isMine || !message.getCreatedAt().isAfter(readAt); int unreadCount = countUnreadSince(message.getCreatedAt(), sortedReadBaselines); return new ChatMessageDetailResponse( @@ -662,6 +678,43 @@ private ChatMessagePageResponse getDirectChatRoomMessages( ); } + private ChatMessagePageResponse getDirectChatRoomMessages( + Integer userId, + Integer roomId, + Integer page, + Integer limit, + LocalDateTime readAt + ) { + ChatRoom chatRoom = getDirectRoom(roomId); + User user = userRepository.getById(userId); + List members = chatRoomMemberRepository.findByChatRoomId(roomId); + LocalDateTime visibleMessageFrom = prepareDirectRoomAccess(getOrCreateDirectRoomMember(chatRoom, user), + chatRoom); + + List sortedReadBaselines = toSortedReadBaselines(members); + + return buildDirectChatRoomMessages(user, roomId, page, limit, readAt, + visibleMessageFrom, sortedReadBaselines, null); + } + + private ChatMessagePageResponse getAdminSystemDirectChatRoomMessages( + User user, + ChatRoom chatRoom, + Integer roomId, + Integer page, + Integer limit, + LocalDateTime readAt + ) { + List members = chatRoomMemberRepository.findByChatRoomId(roomId); + LocalDateTime visibleMessageFrom = resolveAdminSystemRoomVisibleMessageFrom(members); + + List sortedReadBaselines = toAdminChatReadBaselines(members); + Integer maskedAdminId = getMaskedAdminId(user, chatRoom); + + return buildDirectChatRoomMessages(user, roomId, page, limit, readAt, + visibleMessageFrom, sortedReadBaselines, maskedAdminId); + } + private ChatMessageDetailResponse sendDirectMessage( Integer userId, Integer roomId, @@ -669,19 +722,41 @@ private ChatMessageDetailResponse sendDirectMessage( ) { ChatRoom chatRoom = getDirectRoom(roomId); User sender = userRepository.getById(userId); - ChatRoomMember senderMember = getAccessibleDirectRoomMember(chatRoom, sender); - boolean senderHadLeft = senderMember.hasLeft(); + + // 어드민이 SYSTEM_ADMIN 방에 메시지를 보내는 경우 + boolean isAdminSendingToSystemAdminRoom = sender.isAdmin() + && isSystemAdminRoom(chatRoom); + + ChatRoomMember senderMember = null; + boolean senderHadLeft = false; + + if (!isAdminSendingToSystemAdminRoom) { + senderMember = getAccessibleDirectRoomMember(chatRoom, sender); + senderHadLeft = senderMember.hasLeft(); + } + List members = chatRoomMemberRepository.findByChatRoomId(roomId); - User receiver = resolveDirectChatPartner(members, userId); + User receiver = resolveDirectMessageReceiver(members, sender); ChatMessage chatMessage = chatMessageRepository.save( ChatMessage.of(chatRoom, sender, request.content()) ); - if (senderHadLeft) { + + if (senderHadLeft && senderMember != null) { senderMember.restoreDirectRoom(); } + chatRoom.updateLastMessage(chatMessage.getContent(), chatMessage.getCreatedAt()); - updateMemberLastReadAt(roomId, userId, chatMessage.getCreatedAt()); + members.stream() + .filter(member -> !member.getUserId().equals(userId)) + .filter(ChatRoomMember::hasLeft) + .forEach(member -> member.restoreDirectRoomFromIncomingMessage(chatMessage.getCreatedAt())); + + // 어드민이 보낸 경우는 lastReadAt 업데이트하지 않음 (멤버가 아니므로) + if (!isAdminSendingToSystemAdminRoom) { + updateMemberLastReadAt(roomId, userId, chatMessage.getCreatedAt()); + } + List sortedReadBaselines = toSortedReadBaselines(members); notificationService.sendChatNotification(receiver.getId(), roomId, sender.getName(), request.content()); @@ -866,8 +941,6 @@ private ChatMessageDetailResponse sendGroupMessageByRoomId(Integer roomId, Integ } private AccessibleChatRooms getAccessibleChatRooms(Integer userId) { - chatRoomMembershipService.ensureClubRoomMemberships(userId); - List directRooms = getDirectChatRooms(userId); List clubRooms = getClubChatRooms(userId); @@ -1110,7 +1183,7 @@ private Map getUnreadCountMap(List chatRoomIds, Integ } private Integer getMaskedAdminId(User user, ChatRoom chatRoom) { - if (user.getRole() == UserRole.ADMIN) { + if (user.isAdmin()) { return null; } @@ -1132,7 +1205,7 @@ private Integer getMaskedAdminId(User user, ChatRoom chatRoom) { } private void publishAdminChatEventIfNeeded(boolean isSystemAdminRoom, User sender, String content) { - if (isSystemAdminRoom && sender.getRole() != UserRole.ADMIN) { + if (isSystemAdminRoom && !sender.isAdmin()) { eventPublisher.publishEvent(AdminChatReceivedEvent.of(sender.getId(), sender.getName(), content)); } } @@ -1174,6 +1247,10 @@ private void ensureRoomMember(ChatRoom room, User user, LocalDateTime joinedAt) } private void ensureDirectRoomRequester(ChatRoom room, User user, LocalDateTime joinedAt) { + if (shouldSkipSystemAdminMembership(room, user)) { + return; + } + chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId()) .ifPresentOrElse(member -> { if (member.hasLeft()) { @@ -1188,6 +1265,12 @@ private void ensureDirectRoomRequester(ChatRoom room, User user, LocalDateTime j }, () -> chatRoomMemberRepository.save(ChatRoomMember.of(room, user, joinedAt))); } + private boolean shouldSkipSystemAdminMembership(ChatRoom room, User user) { + // 문의방은 SYSTEM_ADMIN + 일반 사용자 2인 구조를 전제로 재사용(findByTwoUsers)되므로, + // 생성/재오픈 경로에서도 일반 ADMIN을 멤버로 추가하면 안 된다. + return user.isAdmin() && isSystemAdminRoom(room); + } + private String normalizeCustomRoomName(String roomName) { if (!StringUtils.hasText(roomName)) { return null; @@ -1221,11 +1304,10 @@ private void updateClubMessageLastReadAt(Integer roomId, Integer userId, LocalDa } private List toSortedReadBaselines(List members) { - List baselines = members.stream() + return members.stream() .map(ChatRoomMember::getLastReadAt) .sorted() .toList(); - return baselines; } private List toAdminChatReadBaselines(List members) { @@ -1233,7 +1315,7 @@ private List toAdminChatReadBaselines(List member LocalDateTime userLastReadAt = null; for (ChatRoomMember member : members) { - if (member.getUser().getRole() == UserRole.ADMIN) { + if (member.getUser().isAdmin()) { if (adminLastReadAt == null || member.getLastReadAt().isAfter(adminLastReadAt)) { adminLastReadAt = member.getLastReadAt(); } @@ -1307,9 +1389,9 @@ private Map getRoomUnreadCountMap(List roomIds, Integ private ChatRoomMember getOrCreateDirectRoomMember(ChatRoom chatRoom, User user) { return chatRoomMemberRepository.findByChatRoomIdAndUserId(chatRoom.getId(), user.getId()) .orElseGet(() -> { - if (user.getRole() == UserRole.ADMIN && isSystemAdminRoom(chatRoom)) { - LocalDateTime joinedAt = LocalDateTime.now(); - return chatRoomMemberRepository.save(ChatRoomMember.of(chatRoom, user, joinedAt)); + // 어드민은 SYSTEM_ADMIN 방에 멤버로 추가되지 않음 + if (user.isAdmin() && isSystemAdminRoom(chatRoom)) { + throw CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS); } throw CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS); }); @@ -1327,6 +1409,11 @@ private LocalDateTime prepareDirectRoomAccess(ChatRoomMember member, ChatRoom ch return visibleMessageFrom; } + private LocalDateTime resolveAdminSystemRoomVisibleMessageFrom(List members) { + ChatRoomMember systemAdminMember = findRoomMember(members, SYSTEM_ADMIN_ID); + return systemAdminMember != null ? systemAdminMember.getVisibleMessageFrom() : null; + } + /** * direct 채팅방에서 나간 사용자가 다시 볼 수 있는 상태인지 확인하고, * 새 메시지가 이미 존재하면 나간 상태를 해제한다. @@ -1354,19 +1441,98 @@ private boolean isSystemAdminRoom(ChatRoom chatRoom) { return userIds.contains(SYSTEM_ADMIN_ID); } + /** + * messageId 조회 전 방 접근 권한을 검증한다. + * 권한 없음과 메시지 미존재를 구분할 수 없게 NOT_FOUND_CHAT_ROOM으로 통일하여 + * 메시지 존재 여부 오라클을 방지한다. + */ + private void ensureMessageLookupAccess(ChatRoom room, User user, Integer userId) { + if (room.isDirectRoom()) { + boolean isMember = chatRoomMemberRepository + .findByChatRoomIdAndUserId(room.getId(), userId) + .isPresent(); + if (!isMember && !(user.isAdmin() && isSystemAdminRoom(room))) { + throw CustomException.of(NOT_FOUND_CHAT_ROOM); + } + } else if (room.isClubGroupRoom()) { + try { + clubMemberRepository.getByClubIdAndUserId(room.getClub().getId(), userId); + } catch (CustomException e) { + // 동아리 멤버십 없음만 404로 변환, 다른 예외는 그대로 전파 + if (e.getErrorCode() == NOT_FOUND_CLUB_MEMBER) { + throw CustomException.of(NOT_FOUND_CHAT_ROOM); + } + throw e; + } + } else { + chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), userId) + .filter(member -> !member.hasLeft()) + .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); + } + } + + /** + * messageId가 가리키는 메시지가 포함된 페이지 번호를 계산한다. + * 가시성 검증 및 정보 누출 방지를 위해 동일한 에러 코드를 사용한다. + */ + private int resolvePageForMessage( + Integer roomId, Integer messageId, ChatRoom room, User user, int limit + ) { + ChatMessage targetMessage = chatMessageRepository.findByIdWithChatRoom(messageId) + .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); + + // 정보 누출 방지를 위해 동일한 에러 코드 사용 + if (!targetMessage.getChatRoom().getId().equals(roomId)) { + throw CustomException.of(NOT_FOUND_CHAT_ROOM); + } + + LocalDateTime visibleMessageFrom = resolveVisibleMessageFromPure(room, user); + + if (visibleMessageFrom != null && !targetMessage.getCreatedAt().isAfter(visibleMessageFrom)) { + throw CustomException.of(NOT_FOUND_CHAT_ROOM); + } + + // NOTE: count와 fetch 사이에 새 메시지가 삽입될 수 있으나, + // 호출부(getMessages)에서 응답에 타겟 메시지가 없으면 1회 재계산함 + long newerCount = chatMessageRepository.countNewerMessagesByChatRoomId( + roomId, messageId, targetMessage.getCreatedAt(), visibleMessageFrom + ); + return (int)(newerCount / limit) + 1; + } + + /** + * 채팅방 타입에 따른 메시지 가시성 기준 시간을 조회한다. + * 기존 getMessages() 흐름의 가시성 로직과 동일한 값을 반환하되, + * 방 복원 등 부수효과는 발생시키지 않는다. + */ + private LocalDateTime resolveVisibleMessageFromPure(ChatRoom room, User user) { + if (!room.isDirectRoom()) { + return null; + } + + if (user.isAdmin() && isSystemAdminRoom(room)) { + List members = chatRoomMemberRepository.findByChatRoomId(room.getId()); + return resolveAdminSystemRoomVisibleMessageFrom(members); + } + + return chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId()) + .map(ChatRoomMember::getVisibleMessageFrom) + .orElse(null); + } + private boolean shouldDisplayAsOwnMessage( User currentUser, ChatMessage message, boolean isAdminViewingSystemRoom ) { if (isAdminViewingSystemRoom) { - return message.getSender().getRole() == UserRole.ADMIN; + return message.getSender().isAdmin(); } return message.isSentBy(currentUser.getId()); } private Integer resolveDirectSenderId(ChatMessage message, Integer maskedAdminId) { - if (maskedAdminId != null && message.getSender().getRole() == UserRole.ADMIN) { + if (maskedAdminId != null && message.getSender().isAdmin()) { return maskedAdminId; } return message.getSender().getId(); @@ -1450,6 +1616,32 @@ private User resolveDirectChatPartner(List members, Integer user return findDirectPartner(members, userId); } + private User findNonAdminUser(List members) { + Map userMap = members.stream() + .collect(Collectors.toMap( + ChatRoomMember::getUserId, + ChatRoomMember::getUser, + (existing, replacement) -> existing + )); + List memberInfos = members.stream() + .map(m -> new MemberInfo(m.getUserId(), m.getCreatedAt())) + .toList(); + return findNonAdminUserFromMemberInfo(memberInfos, userMap); + } + + private User resolveDirectMessageReceiver(List members, User sender) { + Map userMap = members.stream() + .collect(Collectors.toMap( + ChatRoomMember::getUserId, + ChatRoomMember::getUser, + (existing, replacement) -> existing + )); + List memberInfos = members.stream() + .map(m -> new MemberInfo(m.getUserId(), m.getCreatedAt())) + .toList(); + return resolveMessageReceiverFromMemberInfo(sender, memberInfos, userMap); + } + private User findDirectPartnerFromMemberInfo( List memberInfos, Integer userId, @@ -1482,7 +1674,7 @@ private User findNonAdminUserFromMemberInfo(List memberInfos, Map userMap.get(info.userId())) .filter(Objects::nonNull) - .filter(user -> user.getRole() != UserRole.ADMIN) + .filter(user -> !user.isAdmin()) .findFirst() .orElse(null); } @@ -1492,7 +1684,7 @@ private User resolveMessageReceiverFromMemberInfo( List memberInfos, Map userMap ) { - if (sender.getRole() == UserRole.ADMIN) { + if (sender.isAdmin()) { User nonAdminUser = findNonAdminUserFromMemberInfo(memberInfos, userMap); if (nonAdminUser != null) { return nonAdminUser; diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubApplicationApi.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubApplicationApi.java index 6ca48c78b..ade43b00e 100644 --- a/src/main/java/gg/agit/konect/domain/club/controller/ClubApplicationApi.java +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubApplicationApi.java @@ -34,7 +34,7 @@ public interface ClubApplicationApi { 동아리 가입 신청서를 제출합니다. 설문 질문이 없는 경우 answers는 빈 배열을 전달합니다. 모집 공고에서 회비 납부가 필요한 경우(isFeeRequired=true), feePaymentImageUrl은 필수입니다. - + ## 에러 - NOT_FOUND_CLUB (404): 동아리를 찾을 수 없습니다. - NOT_FOUND_USER (404): 유저를 찾을 수 없습니다. @@ -82,7 +82,7 @@ ResponseEntity getClubApplications( - 정렬 기준: APPLIED_AT(신청 일시), STUDENT_NUMBER(학번), NAME(이름) - 정렬 방향: ASC(오름차순), DESC(내림차순) - 기본 정렬: 신청 일시 오래된 순 (APPLIED_AT ASC) - + ## 에러 - FORBIDDEN_CLUB_MANAGER_ACCESS (403): 동아리 매니저 권한이 없습니다. - NOT_FOUND_CLUB (404): 동아리를 찾을 수 없습니다. @@ -102,7 +102,7 @@ ResponseEntity getApprovedMemberApplications( @Operation(summary = "승인된 회원의 동아리 지원 답변을 조회한다.", description = """ - 동아리 관리자만 해당 동아리의 승인된 회원 지원 답변을 조회할 수 있습니다. - 사용자 ID(userId) 기준으로 해당 사용자의 지원서를 찾아 문항/답변을 반환합니다. - + ## 에러 - FORBIDDEN_CLUB_MANAGER_ACCESS (403): 동아리 매니저 권한이 없습니다. - NOT_FOUND_CLUB (404): 동아리를 찾을 수 없습니다. @@ -122,7 +122,7 @@ ResponseEntity getApprovedMemberApplicationAnswe - 정렬 기준: APPLIED_AT(신청 일시), STUDENT_NUMBER(학번), NAME(이름) - 정렬 방향: ASC(오름차순), DESC(내림차순) - 기본 정렬: 신청 일시 오래된 순 (APPLIED_AT ASC) - + ## 에러 - FORBIDDEN_CLUB_MANAGER_ACCESS (403): 동아리 매니저 권한이 없습니다. - NOT_FOUND_CLUB (404): 동아리를 찾을 수 없습니다. @@ -141,7 +141,7 @@ ResponseEntity getApprovedMemberApplicatio @Operation(summary = "동아리 지원 답변을 조회한다.", description = """ - 동아리 관리자만 해당 동아리의 지원 답변을 조회할 수 있습니다. - + ## 에러 - FORBIDDEN_CLUB_MANAGER_ACCESS (403): 동아리 매니저 권한이 없습니다. - NOT_FOUND_CLUB (404): 동아리를 찾을 수 없습니다. @@ -157,9 +157,10 @@ ResponseEntity getClubApplicationAnswers( @Operation(summary = "동아리 가입 신청을 승인한다.", description = """ 동아리 운영진 권한부터 가입 신청을 승인할 수 있습니다. 승인 시 지원자는 일반회원으로 등록되며, 지원 내역은 보관됩니다. - + ## 에러 - ALREADY_CLUB_MEMBER (409): 이미 동아리 회원입니다. + - ALREADY_PROCESSED_CLUB_APPLY (409): 이미 처리된 동아리 가입 신청입니다. - FORBIDDEN_CLUB_MANAGER_ACCESS (403): 동아리 매니저 권한이 없습니다. - NOT_FOUND_CLUB (404): 동아리를 찾을 수 없습니다. - NOT_FOUND_CLUB_APPLY (404): 동아리 지원 내역을 찾을 수 없습니다. @@ -176,6 +177,7 @@ ResponseEntity approveClubApplication( 거절 시 상태를 REJECTED로 변경합니다. ## 에러 + - ALREADY_PROCESSED_CLUB_APPLY (409): 이미 처리된 동아리 가입 신청입니다. - FORBIDDEN_CLUB_MANAGER_ACCESS (403): 동아리 매니저 권한이 없습니다. - NOT_FOUND_CLUB (404): 동아리를 찾을 수 없습니다. - NOT_FOUND_CLUB_APPLY (404): 동아리 지원 내역을 찾을 수 없습니다. @@ -216,7 +218,7 @@ ResponseEntity replaceApplyQuestions( @Operation(summary = "동아리 회비 정보를 조회한다.", description = """ 동아리의 회비 계좌 정보를 조회합니다. - + ## 에러 - NOT_FOUND_CLUB (404): 동아리를 찾을 수 없습니다. """) diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberApi.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberApi.java index 718c36ad8..a799a220b 100644 --- a/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberApi.java +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberApi.java @@ -11,6 +11,8 @@ import gg.agit.konect.domain.club.dto.ClubPreMemberAddRequest; import gg.agit.konect.domain.club.dto.ClubPreMemberAddResponse; +import gg.agit.konect.domain.club.dto.ClubPreMemberBatchAddRequest; +import gg.agit.konect.domain.club.dto.ClubPreMemberBatchAddResponse; import gg.agit.konect.domain.club.dto.ClubMemberChangesResponse; import gg.agit.konect.domain.club.dto.ClubMemberResponse; import gg.agit.konect.domain.club.dto.ClubPreMembersResponse; @@ -29,7 +31,7 @@ public interface ClubMemberApi { @Operation(summary = "동아리 회원의 직책을 변경한다.", description = """ 동아리 회장 또는 부회장만 회원의 직책을 변경할 수 있습니다. 자기 자신의 직책은 변경할 수 없으며, 상위 직급만 하위 직급의 회원을 관리할 수 있습니다. - + ## 에러 - CANNOT_CHANGE_OWN_POSITION (400): 자기 자신의 직책은 변경할 수 없습니다. - CANNOT_MANAGE_HIGHER_POSITION (400): 자신보다 높은 직급의 회원은 관리할 수 없습니다. @@ -50,7 +52,7 @@ ResponseEntity changeMemberPosition( @Operation(summary = "동아리 회장 권한을 위임한다.", description = """ 현재 회장만 회장 권한을 다른 회원에게 위임할 수 있습니다. 회장 위임 시 현재 회장은 일반회원으로 강등됩니다. - + ## 에러 - ILLEGAL_ARGUMENT (400): 자기 자신에게는 위임할 수 없습니다. - FORBIDDEN_CLUB_MANAGER_ACCESS (403): 동아리 회장 권한이 없습니다. @@ -67,7 +69,7 @@ ResponseEntity transferPresident( @Operation(summary = "동아리 부회장을 변경한다.", description = """ 동아리 회장만 부회장을 임명하거나 해제할 수 있습니다. vicePresidentUserId가 null이면 부회장을 해제하고, 값이 있으면 해당 회원을 부회장으로 임명합니다. - + ## 에러 - CANNOT_CHANGE_OWN_POSITION (400): 자기 자신을 부회장으로 임명할 수 없습니다. - FORBIDDEN_CLUB_MANAGER_ACCESS (403): 동아리 회장 권한이 없습니다. @@ -93,7 +95,7 @@ ResponseEntity changeVicePresident( - 사전 등록된 회원이 서비스에 가입하면 지정한 직책(clubPosition)으로 자동 전환됩니다. - clubPosition이 PRESIDENT인 경우, 기존 회장 회원 정보는 제거되고 새 가입자가 회장으로 등록됩니다. - 응답의 `isDirectMember`가 `false`로 반환됩니다. - + ## 에러 - ALREADY_CLUB_MEMBER (409): 이미 동아리 회원입니다. (서비스 가입자인 경우) - ALREADY_CLUB_PRE_MEMBER (409): 이미 동아리에 사전 등록된 회원입니다. (서비스 미가입자인 경우) @@ -107,9 +109,31 @@ ResponseEntity addPreMember( @UserId Integer userId ); + @Operation(summary = "학번으로 여러 회원을 동아리에 일괄 등록한다.", description = """ + 운영진 이상만 사전 등록 회원을 일괄 등록할 수 있습니다. + + ## 로직 + - 각 회원은 개별적으로 처리되어, 일부 실패 시에도 나머지는 등록됩니다. + - 해당 학번의 사용자가 이미 서비스에 가입한 경우: 동아리 회원(ClubMember)에 직접 추가됩니다. + - 해당 학번의 사용자가 서비스에 가입하지 않은 경우: 사전 회원(ClubPreMember)으로 등록됩니다. + - 응답의 `success` 필드로 개별 회원의 등록 성공 여부를 확인할 수 있습니다. + + ## 에러 + - INVALID_INPUT (400): 회원 목록은 1~50명까지 가능합니다. + - FORBIDDEN_CLUB_MANAGER_ACCESS (403): 동아리 매니저 권한이 없습니다. + - NOT_FOUND_CLUB (404): 동아리를 찾을 수 없습니다. + """ + ) + @PostMapping("/{clubId}/pre-members/batch") + ResponseEntity addPreMembersBatch( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody ClubPreMemberBatchAddRequest request, + @UserId Integer userId + ); + @Operation(summary = "동아리 사전 등록 회원 리스트를 조회한다.", description = """ 동아리 운영진 권한부터 사전 등록 회원 리스트를 조회할 수 있습니다. - + ## 에러 - FORBIDDEN_CLUB_MANAGER_ACCESS (403): 동아리 매니저 권한이 없습니다. - NOT_FOUND_CLUB (404): 동아리를 찾을 수 없습니다. @@ -122,7 +146,7 @@ ResponseEntity getPreMembers( @Operation(summary = "동아리 사전 등록 회원을 삭제한다.", description = """ 동아리 운영진 권한부터 사전 등록 회원을 삭제할 수 있습니다. - + ## 에러 - FORBIDDEN_CLUB_MANAGER_ACCESS (403): 동아리 매니저 권한이 없습니다. - NOT_FOUND_CLUB (404): 동아리를 찾을 수 없습니다. @@ -138,7 +162,7 @@ ResponseEntity removePreMember( @Operation(summary = "동아리 회원을 강제 탈퇴시킨다.", description = """ 동아리 회장 또는 부회장만 회원을 강제 탈퇴시킬 수 있습니다. 일반회원만 강제 탈퇴 가능하며, 부회장이나 운영진은 먼저 직책을 변경한 후 탈퇴시켜야 합니다. - + ## 에러 - CANNOT_REMOVE_SELF (400): 자기 자신을 강제 탈퇴시킬 수 없습니다. - CANNOT_REMOVE_NON_MEMBER (400): 일반회원만 강제 탈퇴할 수 있습니다. diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberController.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberController.java index 65953a85f..95f71d1d2 100644 --- a/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberController.java +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberController.java @@ -10,6 +10,8 @@ import gg.agit.konect.domain.club.dto.ClubPreMemberAddRequest; import gg.agit.konect.domain.club.dto.ClubPreMemberAddResponse; +import gg.agit.konect.domain.club.dto.ClubPreMemberBatchAddRequest; +import gg.agit.konect.domain.club.dto.ClubPreMemberBatchAddResponse; import gg.agit.konect.domain.club.dto.ClubMemberChangesResponse; import gg.agit.konect.domain.club.dto.ClubMemberResponse; import gg.agit.konect.domain.club.dto.ClubPreMembersResponse; @@ -76,6 +78,17 @@ public ResponseEntity addPreMember( return ResponseEntity.ok(response); } + @Override + public ResponseEntity addPreMembersBatch( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody ClubPreMemberBatchAddRequest request, + @UserId Integer userId + ) { + ClubPreMemberBatchAddResponse response = clubMemberManagementService.addPreMembersBatch(clubId, userId, + request); + return ResponseEntity.ok(response); + } + @Override public ResponseEntity getPreMembers( @PathVariable(name = "clubId") Integer clubId, diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationApi.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationApi.java index 9c4bd1d5c..efb4f8f07 100644 --- a/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationApi.java +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationApi.java @@ -7,6 +7,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import gg.agit.konect.domain.club.dto.ClubMemberSheetSyncResponse; +import gg.agit.konect.domain.club.dto.SheetImportConfirmRequest; +import gg.agit.konect.domain.club.dto.SheetImportPreviewResponse; import gg.agit.konect.domain.club.dto.SheetImportRequest; import gg.agit.konect.domain.club.dto.SheetImportResponse; import gg.agit.konect.domain.club.dto.SheetMigrateRequest; @@ -20,11 +22,11 @@ public interface ClubSheetMigrationApi { @Operation( - summary = "기존 스프레드시트 → 팀 양식으로 이관", - description = "동아리가 기존에 사용하던 스프레드시트 URL을 제출하면, " - + "AI가 데이터를 분석하여 KONECT 팀이 마련한 표준 양식 파일로 복사합니다. " - + "새 파일은 기존 URL과 동일한 Google Drive 폴더에 생성됩니다. " - + "이후 동기화는 새로 생성된 파일 기준으로 진행됩니다." + summary = "기존 스프레드시트를 공식 시트로 이관한다", + description = """ + 기존 스프레드시트 URL을 제출하면 + 같은 Google Drive 폴더에 KONECT 공식 시트를 만들고 현재 데이터를 복사합니다. + """ ) @PostMapping("/{clubId}/sheet/migrate") ResponseEntity migrateSheet( @@ -34,10 +36,39 @@ ResponseEntity migrateSheet( ); @Operation( - summary = "기존 스프레드시트에서 사전 회원 가져오기", - description = "동아리가 기존에 관리하던 스프레드시트의 인명부를 읽어 " - + "DB에 사전 회원(ClubPreMember)으로 등록합니다. " - + "AI가 헤더를 자동 분석하며, 이미 등록된 회원(이름+학번 중복)은 건너뜁니다." + summary = "시트 불러오기 전 부원 목록을 미리본다", + description = """ + PUT /clubs/{clubId}/sheet 로 AI 분석 및 등록이 완료된 스프레드시트를 읽어 + 등록 예정인 부원 목록을 JSON으로 반환합니다. + 이 API는 데이터를 저장하지 않고 미리보기 용도로만 사용합니다. + """ + ) + @PostMapping("/{clubId}/sheet/import/preview") + ResponseEntity previewPreMembers( + @PathVariable(name = "clubId") Integer clubId, + @UserId Integer requesterId + ); + + @Operation( + summary = "편집된 미리보기 부원 목록을 최종 등록한다", + description = """ + 미리보기 화면에서 활성화된 부원 목록과 수동 추가한 부원 목록을 최종본으로 받아 등록합니다. + 비활성화된 부원은 등록 대상에서 제외됩니다. + """ + ) + @PostMapping("/{clubId}/sheet/import/confirm") + ResponseEntity confirmImportPreMembers( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody SheetImportConfirmRequest request, + @UserId Integer requesterId + ); + + @Operation( + summary = "스프레드시트에서 사전 등록 부원을 가져온다", + description = """ + 스프레드시트의 부원 정보를 데이터베이스에 반영합니다. + 가입된 사용자는 ClubMember로 바로 등록하고, 미가입 사용자는 ClubPreMember로 저장합니다. + """ ) @PostMapping("/{clubId}/sheet/import") ResponseEntity importPreMembers( @@ -47,10 +78,11 @@ ResponseEntity importPreMembers( ); @Operation( - summary = "스프레드시트 분석 후 사전 회원 가져오기", - description = "구글 스프레드시트 URL을 받아 먼저 시트를 분석 및 등록한 뒤, " - + "같은 스프레드시트에서 사전 회원을 읽어 DB에 등록합니다. " - + "기존 PUT /clubs/{clubId}/sheet 와 POST /clubs/{clubId}/sheet/import 를 순서대로 실행한 결과와 동일합니다." + summary = "시트 분석과 사전 등록 부원 가져오기를 한 번에 수행한다", + description = """ + 시트 분석, 시트 등록, 사전 등록 부원 가져오기를 순차적으로 수행합니다. + 기존 PUT /clubs/{clubId}/sheet 이후 POST /clubs/{clubId}/sheet/import 를 호출한 것과 같은 결과입니다. + """ ) @PostMapping("/{clubId}/sheet/import/integrated") ResponseEntity analyzeAndImportPreMembers( diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationController.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationController.java index 002d130bd..bcceb2161 100644 --- a/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationController.java +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationController.java @@ -7,6 +7,8 @@ import org.springframework.web.bind.annotation.RestController; import gg.agit.konect.domain.club.dto.ClubMemberSheetSyncResponse; +import gg.agit.konect.domain.club.dto.SheetImportConfirmRequest; +import gg.agit.konect.domain.club.dto.SheetImportPreviewResponse; import gg.agit.konect.domain.club.dto.SheetImportRequest; import gg.agit.konect.domain.club.dto.SheetImportResponse; import gg.agit.konect.domain.club.dto.SheetMigrateRequest; @@ -38,6 +40,31 @@ public ResponseEntity migrateSheet( return ResponseEntity.ok(ClubMemberSheetSyncResponse.of(0, newSpreadsheetId)); } + @Override + public ResponseEntity previewPreMembers( + @PathVariable(name = "clubId") Integer clubId, + @UserId Integer requesterId + ) { + SheetImportPreviewResponse response = sheetImportService.previewPreMembersFromSheet( + clubId, requesterId + ); + return ResponseEntity.ok(response); + } + + @Override + public ResponseEntity confirmImportPreMembers( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody SheetImportConfirmRequest request, + @UserId Integer requesterId + ) { + SheetImportResponse response = sheetImportService.confirmImportPreMembers( + clubId, + requesterId, + request.members() + ); + return ResponseEntity.ok(response); + } + @Override public ResponseEntity importPreMembers( @PathVariable(name = "clubId") Integer clubId, diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubApplicationsResponse.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubApplicationsResponse.java index 1d18cca24..cbe5a1c1d 100644 --- a/src/main/java/gg/agit/konect/domain/club/dto/ClubApplicationsResponse.java +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubApplicationsResponse.java @@ -32,6 +32,9 @@ public record ClubApplicationResponse( @Schema(description = "지원 ID", example = "1", requiredMode = REQUIRED) Integer id, + @Schema(description = "유저 ID", example = "1", requiredMode = REQUIRED) + Integer userId, + @Schema(description = "지원자 학번", example = "20250120", requiredMode = REQUIRED) String studentNumber, @@ -51,6 +54,7 @@ public record ClubApplicationResponse( public static ClubApplicationResponse from(ClubApply clubApply) { return new ClubApplicationResponse( clubApply.getId(), + clubApply.getUser().getId(), clubApply.getUser().getStudentNumber(), clubApply.getUser().getName(), clubApply.getUser().getImageUrl(), diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubPreMemberBatchAddRequest.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubPreMemberBatchAddRequest.java new file mode 100644 index 000000000..bc924c104 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubPreMemberBatchAddRequest.java @@ -0,0 +1,18 @@ +package gg.agit.konect.domain.club.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record ClubPreMemberBatchAddRequest( + @NotNull(message = "회원 목록은 필수입니다.") + @Size(min = 1, max = 300, message = "회원은 최소 1명에서 최대 300명까지 등록할 수 있습니다.") + @Schema(description = "사전 등록할 회원 목록", requiredMode = REQUIRED) + @Valid List<@NotNull ClubPreMemberAddRequest> members +) { +} diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubPreMemberBatchAddResponse.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubPreMemberBatchAddResponse.java new file mode 100644 index 000000000..741915186 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubPreMemberBatchAddResponse.java @@ -0,0 +1,32 @@ +package gg.agit.konect.domain.club.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record ClubPreMemberBatchAddResponse( + @Schema(description = "전체 요청 수", example = "10", requiredMode = REQUIRED) + Integer totalCount, + + @Schema(description = "성공한 회원 수", example = "8", requiredMode = REQUIRED) + Integer successCount, + + @Schema(description = "실패한 회원 수", example = "2", requiredMode = REQUIRED) + Integer failedCount, + + @Schema(description = "개별 처리 결과 목록", requiredMode = REQUIRED) + List results +) { + + public static ClubPreMemberBatchAddResponse from(List results) { + int totalCount = results.size(); + int successCount = (int)results.stream() + .filter(ClubPreMemberBatchResultItem::success) + .count(); + int failedCount = totalCount - successCount; + + return new ClubPreMemberBatchAddResponse(totalCount, successCount, failedCount, results); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubPreMemberBatchResultItem.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubPreMemberBatchResultItem.java new file mode 100644 index 000000000..c0a04136d --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubPreMemberBatchResultItem.java @@ -0,0 +1,65 @@ +package gg.agit.konect.domain.club.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import gg.agit.konect.domain.club.enums.ClubPosition; +import gg.agit.konect.global.code.ApiResponseCode; +import io.swagger.v3.oas.annotations.media.Schema; + +public record ClubPreMemberBatchResultItem( + @Schema(description = "학번", example = "2021136089", requiredMode = REQUIRED) + String studentNumber, + + @Schema(description = "이름", example = "홍길동", requiredMode = REQUIRED) + String name, + + @Schema(description = "성공 여부", example = "true", requiredMode = REQUIRED) + Boolean success, + + // 성공 시 반환되는 필드 + @Schema(description = "동아리 고유 ID (성공 시)", example = "1") + Integer clubId, + + @Schema(description = "가입 직책 (성공 시)", example = "MEMBER") + ClubPosition clubPosition, + + @Schema(description = "직접 회원 가입 여부 (성공 시: true면 ClubMember 직접 추가, false면 ClubPreMember 사전등록)", + example = "false") + Boolean isDirectMember, + + // 실패 시 반환되는 필드 + @Schema(description = "오류 코드 (실패 시)", example = "ALREADY_CLUB_MEMBER") + String errorCode, + + @Schema(description = "오류 메시지 (실패 시)", example = "이미 동아리 회원입니다.") + String errorMessage +) { + + public static ClubPreMemberBatchResultItem success(ClubPreMemberAddRequest request, + ClubPreMemberAddResponse response) { + return new ClubPreMemberBatchResultItem( + request.studentNumber(), + request.name(), + true, + response.clubId(), + response.clubPosition(), + response.isDirectMember(), + null, + null + ); + } + + public static ClubPreMemberBatchResultItem fail(ClubPreMemberAddRequest request, + ApiResponseCode errorCode) { + return new ClubPreMemberBatchResultItem( + request.studentNumber(), + request.name(), + false, + null, + null, + null, + errorCode.name(), + errorCode.getMessage() + ); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/dto/SheetImportConfirmRequest.java b/src/main/java/gg/agit/konect/domain/club/dto/SheetImportConfirmRequest.java new file mode 100644 index 000000000..b64f59623 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/SheetImportConfirmRequest.java @@ -0,0 +1,47 @@ +package gg.agit.konect.domain.club.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import gg.agit.konect.domain.club.enums.ClubPosition; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public record SheetImportConfirmRequest( + @NotEmpty(message = "최종 등록할 부원 목록은 최소 1명 이상이어야 합니다.") + @Valid + @ArraySchema(schema = @Schema(implementation = ConfirmMember.class)) + List members +) { + public record ConfirmMember( + @NotEmpty(message = "학번은 필수 입력입니다.") + @Size(min = 4, max = 20, message = "학번은 4자 이상 20자 이하입니다.") + @Pattern(regexp = "^[0-9]+$", message = "학번은 숫자만 입력할 수 있습니다.") + @Schema(description = "학번", example = "2021136089", requiredMode = REQUIRED) + String studentNumber, + + @NotEmpty(message = "이름은 필수 입력입니다.") + @Pattern( + regexp = "^([가-힣]{2,5}|(?=.{2,30}$)[A-Za-z]+( [A-Za-z]+)*)$", + message = "이름은 완성된 한글 2~5자 또는 영문 2~30자(공백 포함)만 입력할 수 있습니다." + ) + @Schema(description = "이름", example = "김코넥트", requiredMode = REQUIRED) + String name, + + @Schema(description = "등록할 동아리 직책", example = "MEMBER", requiredMode = NOT_REQUIRED) + ClubPosition clubPosition, + + @Schema(description = "최종 등록 대상 여부", example = "true", requiredMode = NOT_REQUIRED) + Boolean enabled + ) { + public boolean isEnabled() { + return !Boolean.FALSE.equals(enabled); + } + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/dto/SheetImportPreviewResponse.java b/src/main/java/gg/agit/konect/domain/club/dto/SheetImportPreviewResponse.java new file mode 100644 index 000000000..5386a310c --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/SheetImportPreviewResponse.java @@ -0,0 +1,85 @@ +package gg.agit.konect.domain.club.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import gg.agit.konect.domain.club.enums.ClubPosition; +import gg.agit.konect.domain.club.model.ClubMember; +import gg.agit.konect.domain.club.model.ClubPreMember; +import io.swagger.v3.oas.annotations.media.Schema; + +public record SheetImportPreviewResponse( + @Schema(description = "전체 미리보기 인원 수", example = "10", requiredMode = REQUIRED) + int previewCount, + + @Schema(description = "즉시 등록될 인원 수", example = "2", requiredMode = REQUIRED) + int autoRegisteredCount, + + @Schema(description = "사전 등록될 인원 수", example = "8", requiredMode = REQUIRED) + int preRegisteredCount, + + @Schema(description = "미리보기 부원 목록", requiredMode = REQUIRED) + List members, + + @Schema(description = "미리보기 중 수집된 경고 목록", requiredMode = REQUIRED) + List warnings +) { + public record PreviewMember( + @Schema(description = "학번", example = "2021136089", requiredMode = REQUIRED) + String studentNumber, + + @Schema(description = "이름", example = "김코넥트", requiredMode = REQUIRED) + String name, + + @Schema(description = "등록할 동아리 직책", example = "MEMBER", requiredMode = REQUIRED) + ClubPosition clubPosition, + + @Schema(description = "ClubMember로 즉시 등록되는 경우 true", example = "false", requiredMode = REQUIRED) + Boolean isDirectMember, + + @Schema(description = "최종 등록 대상이면 true", example = "true", requiredMode = REQUIRED) + Boolean enabled + ) { + public static PreviewMember from(ClubMember clubMember) { + return new PreviewMember( + clubMember.getUser().getStudentNumber(), + clubMember.getUser().getName(), + clubMember.getClubPosition(), + true, + true + ); + } + + public static PreviewMember from(ClubPreMember preMember) { + return new PreviewMember( + preMember.getStudentNumber(), + preMember.getName(), + preMember.getClubPosition(), + false, + true + ); + } + } + + public static SheetImportPreviewResponse of( + List members, + List warnings + ) { + List safeMembers = members != null ? members : List.of(); + List safeWarnings = warnings != null ? warnings : List.of(); + + int autoRegisteredCount = (int)safeMembers.stream() + .filter(member -> Boolean.TRUE.equals(member.isDirectMember())) + .count(); + int preRegisteredCount = safeMembers.size() - autoRegisteredCount; + + return new SheetImportPreviewResponse( + safeMembers.size(), + autoRegisteredCount, + preRegisteredCount, + safeMembers, + safeWarnings + ); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/event/ClubApplicationRejectedEvent.java b/src/main/java/gg/agit/konect/domain/club/event/ClubApplicationRejectedEvent.java new file mode 100644 index 000000000..6e0b729da --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/event/ClubApplicationRejectedEvent.java @@ -0,0 +1,11 @@ +package gg.agit.konect.domain.club.event; + +public record ClubApplicationRejectedEvent( + Integer receiverId, + Integer clubId, + String clubName +) { + public static ClubApplicationRejectedEvent of(Integer receiverId, Integer clubId, String clubName) { + return new ClubApplicationRejectedEvent(receiverId, clubId, clubName); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/model/Club.java b/src/main/java/gg/agit/konect/domain/club/model/Club.java index 114c1cc87..222a3c371 100644 --- a/src/main/java/gg/agit/konect/domain/club/model/Club.java +++ b/src/main/java/gg/agit/konect/domain/club/model/Club.java @@ -173,7 +173,7 @@ public void updateInfo(String description, String imageUrl, String location, Str this.introduce = introduce; } - public void updateBasicInfo(String name, ClubCategory clubCategory) { // 어드민 계정으로만 사용 가능 + public void updateBasicInfo(String name, ClubCategory clubCategory) { this.name = name; this.clubCategory = clubCategory; } diff --git a/src/main/java/gg/agit/konect/domain/club/repository/ClubApplyRepository.java b/src/main/java/gg/agit/konect/domain/club/repository/ClubApplyRepository.java index 24ef15c35..3f060de9a 100644 --- a/src/main/java/gg/agit/konect/domain/club/repository/ClubApplyRepository.java +++ b/src/main/java/gg/agit/konect/domain/club/repository/ClubApplyRepository.java @@ -4,10 +4,13 @@ import java.util.List; import java.util.Optional; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; import org.springframework.data.repository.query.Param; +import jakarta.persistence.LockModeType; + import gg.agit.konect.domain.club.model.ClubApply; import gg.agit.konect.domain.club.enums.ClubApplyStatus; import gg.agit.konect.global.code.ApiResponseCode; @@ -58,6 +61,23 @@ default ClubApply getByIdAndClubId(Integer id, Integer clubId) { .orElseThrow(() -> CustomException.of(ApiResponseCode.NOT_FOUND_CLUB_APPLY)); } + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query(""" + SELECT clubApply + FROM ClubApply clubApply + WHERE clubApply.id = :id + AND clubApply.club.id = :clubId + """) + Optional findByIdAndClubIdForUpdate( + @Param("id") Integer id, + @Param("clubId") Integer clubId + ); + + default ClubApply getByIdAndClubIdForUpdate(Integer id, Integer clubId) { + return findByIdAndClubIdForUpdate(id, clubId) + .orElseThrow(() -> CustomException.of(ApiResponseCode.NOT_FOUND_CLUB_APPLY)); + } + @Query(""" SELECT clubApply FROM ClubApply clubApply diff --git a/src/main/java/gg/agit/konect/domain/club/repository/ClubRepository.java b/src/main/java/gg/agit/konect/domain/club/repository/ClubRepository.java index 3cee58933..5dbab27af 100644 --- a/src/main/java/gg/agit/konect/domain/club/repository/ClubRepository.java +++ b/src/main/java/gg/agit/konect/domain/club/repository/ClubRepository.java @@ -21,11 +21,25 @@ public interface ClubRepository extends Repository { """) Optional findById(@Param(value = "id") Integer id); + @Query(value = """ + SELECT c + FROM Club c + LEFT JOIN FETCH c.university + LEFT JOIN FETCH c.clubRecruitment cr + WHERE c.id = :id + """) + Optional findByIdWithUniversity(@Param(value = "id") Integer id); + default Club getById(Integer id) { return findById(id).orElseThrow(() -> CustomException.of(ApiResponseCode.NOT_FOUND_CLUB)); } + default Club getByIdWithUniversity(Integer id) { + return findByIdWithUniversity(id).orElseThrow(() -> + CustomException.of(ApiResponseCode.NOT_FOUND_CLUB)); + } + List findAll(); Club save(Club club); diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubApplicationService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubApplicationService.java index d282d17ac..f2947d4f1 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubApplicationService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubApplicationService.java @@ -2,7 +2,9 @@ import static gg.agit.konect.domain.club.enums.ClubPosition.MEMBER; +import gg.agit.konect.domain.club.enums.ClubApplyStatus; import gg.agit.konect.domain.club.enums.ClubPosition; + import static gg.agit.konect.global.code.ApiResponseCode.*; import java.time.LocalDateTime; @@ -32,6 +34,7 @@ import gg.agit.konect.domain.club.dto.ClubFeeInfoReplaceRequest; import gg.agit.konect.domain.club.dto.ClubFeeInfoResponse; import gg.agit.konect.domain.club.event.ClubApplicationApprovedEvent; +import gg.agit.konect.domain.club.event.ClubApplicationRejectedEvent; import gg.agit.konect.domain.club.event.ClubApplicationSubmittedEvent; import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.club.model.ClubApply; @@ -47,7 +50,6 @@ import gg.agit.konect.domain.club.repository.ClubMemberRepository; import gg.agit.konect.domain.club.repository.ClubRecruitmentRepository; import gg.agit.konect.domain.club.repository.ClubRepository; -import gg.agit.konect.domain.notification.service.NotificationService; import gg.agit.konect.domain.user.model.User; import gg.agit.konect.domain.user.repository.UserRepository; import gg.agit.konect.global.exception.CustomException; @@ -69,7 +71,6 @@ public class ClubApplicationService { private final BankRepository bankRepository; private final ClubPermissionValidator clubPermissionValidator; private final ApplicationEventPublisher applicationEventPublisher; - private final NotificationService notificationService; private final ChatRoomMembershipService chatRoomMembershipService; public ClubAppliedClubsResponse getAppliedClubs(Integer userId) { @@ -231,7 +232,12 @@ public void approveClubApplication(Integer clubId, Integer applicationId, Intege clubPermissionValidator.validateManagerAccess(clubId, userId); - ClubApply clubApply = clubApplyRepository.getByIdAndClubId(applicationId, clubId); + ClubApply clubApply = clubApplyRepository.getByIdAndClubIdForUpdate(applicationId, clubId); + + if (clubApply.getStatus() != ClubApplyStatus.PENDING) { + throw CustomException.of(ALREADY_PROCESSED_CLUB_APPLY); + } + User applicant = clubApply.getUser(); if (clubMemberRepository.existsByClubIdAndUserId(clubId, applicant.getId())) { @@ -261,15 +267,20 @@ public void rejectClubApplication(Integer clubId, Integer applicationId, Integer clubPermissionValidator.validateManagerAccess(clubId, userId); - ClubApply clubApply = clubApplyRepository.getByIdAndClubId(applicationId, clubId); + ClubApply clubApply = clubApplyRepository.getByIdAndClubIdForUpdate(applicationId, clubId); + + if (clubApply.getStatus() != ClubApplyStatus.PENDING) { + throw CustomException.of(ALREADY_PROCESSED_CLUB_APPLY); + } + User applicant = clubApply.getUser(); clubApply.reject(); - notificationService.sendClubApplicationRejectedNotification( + applicationEventPublisher.publishEvent(ClubApplicationRejectedEvent.of( applicant.getId(), clubId, club.getName() - ); + )); } @Transactional diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberManagementService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberManagementService.java index 5a83be744..f3ec690f9 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberManagementService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberManagementService.java @@ -5,14 +5,20 @@ import java.util.ArrayList; import java.util.List; +import java.util.Objects; import java.util.Optional; import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; import gg.agit.konect.domain.chat.service.ChatRoomMembershipService; import gg.agit.konect.domain.club.dto.ClubPreMemberAddRequest; import gg.agit.konect.domain.club.dto.ClubPreMemberAddResponse; +import gg.agit.konect.domain.club.dto.ClubPreMemberBatchAddRequest; +import gg.agit.konect.domain.club.dto.ClubPreMemberBatchAddResponse; +import gg.agit.konect.domain.club.dto.ClubPreMemberBatchResultItem; import gg.agit.konect.domain.club.dto.ClubPreMembersResponse; import gg.agit.konect.domain.club.dto.MemberPositionChangeRequest; import gg.agit.konect.domain.club.dto.PresidentTransferRequest; @@ -42,6 +48,7 @@ public class ClubMemberManagementService { private final ClubPermissionValidator clubPermissionValidator; private final UserRepository userRepository; private final ChatRoomMembershipService chatRoomMembershipService; + private final PlatformTransactionManager transactionManager; @Transactional public ClubMember changeMemberPosition( @@ -54,19 +61,24 @@ public ClubMember changeMemberPosition( validateNotSelf(requesterId, targetUserId, CANNOT_CHANGE_OWN_POSITION); - clubPermissionValidator.validateLeaderAccess(clubId, requesterId); + User requesterUser = userRepository.getById(requesterId); + clubPermissionValidator.validateLeaderAccess(clubId, requesterUser); - ClubMember requester = clubMemberRepository.getByClubIdAndUserId(clubId, requesterId); + boolean isAdminRequester = requesterUser.isAdmin(); ClubMember target = clubMemberRepository.getByClubIdAndUserId(clubId, targetUserId); - if (!requester.canManage(target)) { - throw CustomException.of(CANNOT_MANAGE_HIGHER_POSITION); - } - ClubPosition newPosition = request.position(); - if (!requester.getClubPosition().canManage(newPosition)) { - throw CustomException.of(FORBIDDEN_MEMBER_POSITION_CHANGE); + if (!isAdminRequester) { + ClubMember requester = clubMemberRepository.getByClubIdAndUserId(clubId, requesterId); + + if (!requester.canManage(target)) { + throw CustomException.of(CANNOT_MANAGE_HIGHER_POSITION); + } + + if (!requester.getClubPosition().canManage(newPosition)) { + throw CustomException.of(FORBIDDEN_MEMBER_POSITION_CHANGE); + } } validatePositionLimit(clubId, newPosition, target); @@ -86,6 +98,13 @@ public ClubPreMemberAddResponse addPreMember( clubPermissionValidator.validateManagerAccess(clubId, requesterId); + return processPreMemberWithoutValidation(club, request); + } + + private ClubPreMemberAddResponse processPreMemberWithoutValidation( + Club club, + ClubPreMemberAddRequest request + ) { String studentNumber = request.studentNumber(); String name = request.name(); ClubPosition clubPosition = request.clubPosition() == null ? MEMBER : request.clubPosition(); @@ -156,6 +175,34 @@ private ClubPreMemberAddResponse addPreMemberInternal( return ClubPreMemberAddResponse.from(savedPreMember); } + public ClubPreMemberBatchAddResponse addPreMembersBatch( + Integer clubId, + Integer requesterId, + ClubPreMemberBatchAddRequest request + ) { + Club club = clubRepository.getById(clubId); + + clubPermissionValidator.validateManagerAccess(clubId, requesterId); + + List results = new ArrayList<>(); + TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); + + for (ClubPreMemberAddRequest memberRequest : request.members()) { + try { + ClubPreMemberAddResponse response = Objects.requireNonNull( + transactionTemplate.execute(status -> processPreMemberWithoutValidation(club, memberRequest)) + ); + results.add(ClubPreMemberBatchResultItem.success(memberRequest, response)); + } catch (CustomException e) { + results.add(ClubPreMemberBatchResultItem.fail(memberRequest, e.getErrorCode())); + } catch (Exception e) { + results.add(ClubPreMemberBatchResultItem.fail(memberRequest, UNEXPECTED_SERVER_ERROR)); + } + } + + return ClubPreMemberBatchAddResponse.from(results); + } + public ClubPreMembersResponse getPreMembers(Integer clubId, Integer requesterId) { clubRepository.getById(clubId); diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubPermissionValidator.java b/src/main/java/gg/agit/konect/domain/club/service/ClubPermissionValidator.java index 2a84da2ac..9c46c497d 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubPermissionValidator.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubPermissionValidator.java @@ -9,6 +9,7 @@ import gg.agit.konect.domain.club.enums.ClubPosition; import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.user.model.User; import gg.agit.konect.domain.user.repository.UserRepository; import gg.agit.konect.global.exception.CustomException; import lombok.RequiredArgsConstructor; @@ -22,7 +23,7 @@ public class ClubPermissionValidator { public void validatePresidentAccess(Integer clubId, Integer userId) { if (isAdmin(userId)) { - return ; + return; } if (!hasAccess(clubId, userId, PRESIDENT_ONLY)) { @@ -31,8 +32,14 @@ public void validatePresidentAccess(Integer clubId, Integer userId) { } public void validateLeaderAccess(Integer clubId, Integer userId) { - if (isAdmin(userId)) { - return ; + validateLeaderAccess(clubId, userRepository.getById(userId)); + } + + public void validateLeaderAccess(Integer clubId, User user) { + Integer userId = user.getId(); + + if (user.isAdmin()) { + return; } if (!hasAccess(clubId, userId, LEADERS)) { @@ -41,8 +48,14 @@ public void validateLeaderAccess(Integer clubId, Integer userId) { } public void validateManagerAccess(Integer clubId, Integer userId) { - if (isAdmin(userId)) { - return ; + validateManagerAccess(clubId, userRepository.getById(userId)); + } + + public void validateManagerAccess(Integer clubId, User user) { + Integer userId = user.getId(); + + if (user.isAdmin()) { + return; } if (!hasAccess(clubId, userId, MANAGERS)) { diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubService.java index 08c4dccc6..734bb2226 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubService.java @@ -162,13 +162,10 @@ public void updateInfo(Integer clubId, Integer userId, ClubUpdateRequest request @Transactional public void updateBasicInfo(Integer clubId, Integer userId, ClubBasicInfoUpdateRequest request) { - userRepository.getById(userId); + User user = userRepository.getById(userId); Club club = clubRepository.getById(clubId); - // TODO: 어드민 권한 체크 로직 추가 필요 (현재는 미구현) - // if (!isAdmin(userId)) { - // throw CustomException.of(FORBIDDEN_CLUB_MANAGER_ACCESS); - // } + clubPermissionValidator.validateManagerAccess(clubId, user); club.updateBasicInfo(request.name(), request.clubCategory()); } @@ -199,7 +196,7 @@ public MyManagedClubResponse getManagedClubDetail(Integer clubId, Integer userId return MyManagedClubResponse.forAdmin(club, user); } - clubPermissionValidator.validateManagerAccess(clubId, userId); + clubPermissionValidator.validateManagerAccess(clubId, user); ClubMember clubMember = clubMemberRepository.getByClubIdAndUserId(clubId, userId); return MyManagedClubResponse.from(club, clubMember); diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedService.java index 8959dc03e..1816ef2e5 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedService.java @@ -23,7 +23,6 @@ public SheetImportResponse analyzeAndImportPreMembers( clubPermissionValidator.validateManagerAccess(clubId, requesterId); String spreadsheetId = SpreadsheetUrlParser.extractId(spreadsheetUrl); - // OAuth 미연결이면 건너뛰고 계속 진행한다. Drive 초기화/인증 오류는 예외로 전파한다. googleSheetPermissionService.tryGrantServiceAccountWriterAccess(requesterId, spreadsheetId); SheetHeaderMapper.SheetAnalysisResult analysis = diff --git a/src/main/java/gg/agit/konect/domain/club/service/GoogleDrivePermissionHelper.java b/src/main/java/gg/agit/konect/domain/club/service/GoogleDrivePermissionHelper.java index 918fb12df..0efc2663e 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/GoogleDrivePermissionHelper.java +++ b/src/main/java/gg/agit/konect/domain/club/service/GoogleDrivePermissionHelper.java @@ -94,7 +94,8 @@ static List listAllPermissions(Drive driveService, String fileId) th do { Drive.Permissions.List request = driveService.permissions().list(fileId) - .setFields(PERMISSION_FIELDS); + .setFields(PERMISSION_FIELDS) + .setSupportsAllDrives(true); if (nextPageToken != null) { request.setPageToken(nextPageToken); } @@ -142,6 +143,7 @@ private static PermissionApplyStatus applyServiceAccountPermission( userDriveService.permissions().create(fileId, permission) .setSendNotificationEmail(false) + .setSupportsAllDrives(true) .execute(); log.info( "Service account {} access granted. fileId={}, email={}", @@ -165,6 +167,7 @@ private static PermissionApplyStatus applyServiceAccountPermission( Permission updatedPermission = new Permission().setRole(targetRole); userDriveService.permissions().update(fileId, existingPermission.getId(), updatedPermission) + .setSupportsAllDrives(true) .execute(); log.info( "Service account permission upgraded. fileId={}, fromRole={}, toRole={}, email={}", diff --git a/src/main/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionService.java b/src/main/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionService.java index c01c588a7..cfa96d70f 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionService.java @@ -7,6 +7,7 @@ import org.springframework.util.StringUtils; import com.google.api.services.drive.Drive; +import com.google.api.services.drive.model.File; import com.google.auth.oauth2.ServiceAccountCredentials; import gg.agit.konect.domain.user.enums.Provider; @@ -23,32 +24,63 @@ public class GoogleSheetPermissionService { private final ServiceAccountCredentials serviceAccountCredentials; + private final Drive googleDriveService; private final GoogleSheetsConfig googleSheetsConfig; private final UserOAuthAccountRepository userOAuthAccountRepository; + public void validateRequesterAccessAndTryGrantServiceAccountWriterAccess( + Integer requesterId, + String spreadsheetId + ) { + String refreshToken = requireRefreshToken(requesterId); + Drive userDriveService = buildUserDriveService(refreshToken, requesterId); + validateRequesterSpreadsheetAccess(userDriveService, requesterId, spreadsheetId); + boolean granted = tryGrantServiceAccountWriterAccess(userDriveService, requesterId, spreadsheetId); + if (!granted) { + requireServiceAccountSpreadsheetAccess(spreadsheetId, requesterId); + } + } + public boolean tryGrantServiceAccountWriterAccess(Integer requesterId, String spreadsheetId) { - String refreshToken = userOAuthAccountRepository - .findByUserIdAndProvider(requesterId, Provider.GOOGLE) - .map(account -> account.getGoogleDriveRefreshToken()) - .filter(StringUtils::hasText) - .orElse(null); + String refreshToken = resolveRefreshToken(requesterId); if (refreshToken == null) { - log.warn( + log.info( "Skipping service account auto-share because Google Drive OAuth is not connected. requesterId={}", requesterId ); return false; } - Drive userDriveService; - try { - userDriveService = googleSheetsConfig.buildUserDriveService(refreshToken); - } catch (IOException | GeneralSecurityException e) { - log.error("Failed to build user Drive service. requesterId={}", requesterId, e); - throw CustomException.of(ApiResponseCode.FAILED_INIT_GOOGLE_DRIVE); - } + Drive userDriveService = buildUserDriveService(refreshToken, requesterId); + return tryGrantServiceAccountWriterAccess(userDriveService, requesterId, spreadsheetId); + } + private String requireRefreshToken(Integer requesterId) { + return userOAuthAccountRepository.findByUserIdAndProvider(requesterId, Provider.GOOGLE) + .map(account -> account.getGoogleDriveRefreshToken()) + .filter(StringUtils::hasText) + .orElseThrow(() -> { + log.warn( + "Rejecting spreadsheet registration because Google Drive OAuth is not connected. requesterId={}", + requesterId + ); + return CustomException.of(ApiResponseCode.NOT_FOUND_GOOGLE_DRIVE_AUTH); + }); + } + + private String resolveRefreshToken(Integer requesterId) { + return userOAuthAccountRepository.findByUserIdAndProvider(requesterId, Provider.GOOGLE) + .map(account -> account.getGoogleDriveRefreshToken()) + .filter(StringUtils::hasText) + .orElse(null); + } + + private boolean tryGrantServiceAccountWriterAccess( + Drive userDriveService, + Integer requesterId, + String spreadsheetId + ) { try { GoogleDrivePermissionHelper.ensureServiceAccountPermission( userDriveService, @@ -59,14 +91,14 @@ public boolean tryGrantServiceAccountWriterAccess(Integer requesterId, String sp return true; } catch (IOException e) { if (GoogleSheetApiExceptionHelper.isInvalidGrant(e)) { - log.warn( - "Google Drive OAuth token is invalid while auto-sharing spreadsheet. requesterId={}, " + log.info( + "Skipping service account auto-share because Google Drive OAuth token is invalid. requesterId={}, " + "spreadsheetId={}, cause={}", requesterId, spreadsheetId, GoogleSheetApiExceptionHelper.extractDetail(e) ); - throw GoogleSheetApiExceptionHelper.invalidGoogleDriveAuth(e); + return false; } if (GoogleSheetApiExceptionHelper.isAccessDenied(e) @@ -91,6 +123,105 @@ public boolean tryGrantServiceAccountWriterAccess(Integer requesterId, String sp } } + private Drive buildUserDriveService(String refreshToken, Integer requesterId) { + try { + return googleSheetsConfig.buildUserDriveService(refreshToken); + } catch (IOException | GeneralSecurityException e) { + log.error("Failed to build user Drive service. requesterId={}", requesterId, e); + throw CustomException.of(ApiResponseCode.FAILED_INIT_GOOGLE_DRIVE); + } + } + + private void validateRequesterSpreadsheetAccess( + Drive userDriveService, + Integer requesterId, + String spreadsheetId + ) { + try { + File file = userDriveService.files().get(spreadsheetId) + .setFields("id") + .setSupportsAllDrives(true) + .execute(); + if (file == null || !StringUtils.hasText(file.getId())) { + throw GoogleSheetApiExceptionHelper.accessDenied(); + } + } catch (IOException e) { + if (GoogleSheetApiExceptionHelper.isInvalidGrant(e)) { + log.warn( + "Google Drive OAuth token is invalid while validating spreadsheet access. requesterId={}, " + + "spreadsheetId={}, cause={}", + requesterId, + spreadsheetId, + GoogleSheetApiExceptionHelper.extractDetail(e) + ); + throw GoogleSheetApiExceptionHelper.invalidGoogleDriveAuth(e); + } + + if (GoogleSheetApiExceptionHelper.isAuthFailure(e)) { + log.warn( + "Google Drive OAuth auth failure while validating spreadsheet access. requesterId={}, " + + "spreadsheetId={}, cause={}", + requesterId, + spreadsheetId, + GoogleSheetApiExceptionHelper.extractDetail(e) + ); + throw GoogleSheetApiExceptionHelper.invalidGoogleDriveAuth(e); + } + + if (GoogleSheetApiExceptionHelper.isAccessDenied(e) + || GoogleSheetApiExceptionHelper.isNotFound(e)) { + log.warn( + "Requester has no spreadsheet access. requesterId={}, spreadsheetId={}, cause={}", + requesterId, + spreadsheetId, + e.getMessage() + ); + throw GoogleSheetApiExceptionHelper.accessDenied(); + } + + log.error( + "Unexpected error while validating requester spreadsheet access. requesterId={}, spreadsheetId={}", + requesterId, + spreadsheetId, + e + ); + throw CustomException.of(ApiResponseCode.FAILED_SYNC_GOOGLE_SHEET); + } + } + + private void requireServiceAccountSpreadsheetAccess(String spreadsheetId, Integer requesterId) { + try { + File file = googleDriveService.files().get(spreadsheetId) + .setFields("id") + .setSupportsAllDrives(true) + .execute(); + if (file == null || !StringUtils.hasText(file.getId())) { + throw GoogleSheetApiExceptionHelper.accessDenied(); + } + } catch (IOException e) { + if (GoogleSheetApiExceptionHelper.isAccessDenied(e) + || GoogleSheetApiExceptionHelper.isNotFound(e)) { + log.warn( + "Service account has no spreadsheet access after auto-share failed. requesterId={}, " + + "spreadsheetId={}, cause={}", + requesterId, + spreadsheetId, + e.getMessage() + ); + throw GoogleSheetApiExceptionHelper.accessDenied(); + } + + log.error( + "Unexpected error while re-checking service account spreadsheet access. requesterId={}, " + + "spreadsheetId={}", + requesterId, + spreadsheetId, + e + ); + throw CustomException.of(ApiResponseCode.FAILED_SYNC_GOOGLE_SHEET); + } + } + private String getServiceAccountEmail() { return serviceAccountCredentials.getClientEmail(); } diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java b/src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java index 45e10e63d..d49f1ddd7 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java @@ -6,16 +6,24 @@ import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; +import java.util.function.Supplier; import java.util.stream.Collectors; import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import com.google.api.services.sheets.v4.Sheets; import com.google.api.services.sheets.v4.model.ValueRange; import gg.agit.konect.domain.chat.service.ChatRoomMembershipService; +import gg.agit.konect.domain.club.dto.SheetImportConfirmRequest; +import gg.agit.konect.domain.club.dto.SheetImportPreviewResponse; import gg.agit.konect.domain.club.dto.SheetImportResponse; import gg.agit.konect.domain.club.enums.ClubPosition; import gg.agit.konect.domain.club.model.Club; @@ -46,27 +54,99 @@ public class SheetImportService { private final UserRepository userRepository; private final ChatRoomMembershipService chatRoomMembershipService; private final ClubPermissionValidator clubPermissionValidator; + private final PlatformTransactionManager transactionManager; + private final ObjectMapper objectMapper; + + public SheetImportPreviewResponse previewPreMembersFromSheet( + Integer clubId, + Integer requesterId + ) { + clubPermissionValidator.validateManagerAccess(clubId, requesterId); + Club club = resolveClubWithRegisteredSheet(clubId); + String spreadsheetId = club.getGoogleSheetId(); + SheetColumnMapping mapping = resolveRegisteredMemberListMapping(club); + SheetImportSource source = loadSheetImportSource(spreadsheetId, mapping); + SheetImportPlan plan = buildImportPlan( + clubId, + club, + source.members(), + source.warnings() + ); + return SheetImportPreviewResponse.of(plan.previewMembers(), plan.warnings()); + } + + private Club resolveClubWithRegisteredSheet(Integer clubId) { + Club club = clubRepository.getByIdWithUniversity(clubId); + String spreadsheetId = club.getGoogleSheetId(); + if (spreadsheetId == null || spreadsheetId.isBlank()) { + throw CustomException.of(ApiResponseCode.CLUB_SHEET_ANALYSIS_REQUIRED); + } + return club; + } + + private SheetColumnMapping resolveRegisteredMemberListMapping(Club club) { + String mappingJson = club.getSheetColumnMapping(); + if (mappingJson == null || mappingJson.isBlank()) { + throw CustomException.of(ApiResponseCode.CLUB_SHEET_ANALYSIS_REQUIRED); + } + + try { + Map raw = objectMapper.readValue(mappingJson, new TypeReference<>() {}); + int dataStartRow = raw.containsKey("dataStartRow") + ? ((Number)raw.get("dataStartRow")).intValue() : 2; + Map fieldMap = new HashMap<>(); + raw.forEach((key, value) -> { + if (!"dataStartRow".equals(key) && value instanceof Number number) { + fieldMap.put(key, number.intValue()); + } + }); + return new SheetColumnMapping(fieldMap, dataStartRow); + } catch (Exception e) { + throw CustomException.of(ApiResponseCode.CLUB_SHEET_ANALYSIS_REQUIRED); + } + } @Transactional + public SheetImportResponse confirmImportPreMembers( + Integer clubId, + Integer requesterId, + List members + ) { + clubPermissionValidator.validateManagerAccess(clubId, requesterId); + Club club = clubRepository.getById(clubId); + + SheetImportPlan plan = buildImportPlan( + clubId, + club, + toImportMembers(members), + List.of() + ); + applyImportPlan(clubId, "preview-confirm", plan); + return SheetImportResponse.of( + plan.preRegisteredCount(), + plan.autoRegisteredCount(), + plan.warnings() + ); + } + public SheetImportResponse importPreMembersFromSheet( Integer clubId, Integer requesterId, String spreadsheetUrl ) { clubPermissionValidator.validateManagerAccess(clubId, requesterId); - String spreadsheetId = SpreadsheetUrlParser.extractId(spreadsheetUrl); + String spreadsheetId = SpreadsheetUrlParser.extractId(spreadsheetUrl); SheetHeaderMapper.SheetAnalysisResult analysis = sheetHeaderMapper.analyzeAllSheets(spreadsheetId); - return importPreMembersFromSheet( + SheetImportSource source = loadSheetImportSource(spreadsheetId, analysis.memberListMapping()); + return executeTransaction(() -> importPreMembers( clubId, - requesterId, spreadsheetId, - analysis.memberListMapping() - ); + source + )); } - @Transactional SheetImportResponse importPreMembersFromSheet( Integer clubId, Integer requesterId, @@ -74,105 +154,145 @@ SheetImportResponse importPreMembersFromSheet( SheetColumnMapping mapping ) { clubPermissionValidator.validateManagerAccess(clubId, requesterId); + SheetImportSource source = loadSheetImportSource(spreadsheetId, mapping); + return executeTransaction(() -> importPreMembers( + clubId, + spreadsheetId, + source + )); + } + + private SheetImportResponse importPreMembers( + Integer clubId, + String importSource, + SheetImportSource source + ) { Club club = clubRepository.getById(clubId); - return importPreMembersFromSheet(clubId, club, spreadsheetId, mapping); + SheetImportPlan plan = buildImportPlan( + clubId, + club, + source.members(), + source.warnings() + ); + applyImportPlan(clubId, importSource, plan); + return SheetImportResponse.of( + plan.preRegisteredCount(), + plan.autoRegisteredCount(), + plan.warnings() + ); } - private SheetImportResponse importPreMembersFromSheet( + private void applyImportPlan( + Integer clubId, + String importSource, + SheetImportPlan plan + ) { + if (!plan.studentNumbersToCleanFromPre().isEmpty()) { + clubPreMemberRepository.deleteByClubIdAndStudentNumberIn( + clubId, + plan.studentNumbersToCleanFromPre() + ); + } + + List savedMembers = plan.clubMembersToSave().isEmpty() + ? List.of() + : clubMemberRepository.saveAll(plan.clubMembersToSave()); + + for (ClubMember savedMember : savedMembers) { + chatRoomMembershipService.addClubMember(savedMember); + } + + if (!plan.preMembersToSave().isEmpty()) { + clubPreMemberRepository.saveAll(plan.preMembersToSave()); + } + + log.info( + "Sheet import done. clubId={}, source={}, imported={}, autoRegistered={}, warnings={}", + clubId, + importSource, + plan.preRegisteredCount(), + plan.autoRegisteredCount(), + plan.warnings().size() + ); + } + + private SheetImportPlan buildImportPlan( Integer clubId, Club club, - String spreadsheetId, - SheetColumnMapping mapping + List members, + List initialWarnings ) { Integer universityId = club.getUniversity().getId(); - List> rows = readDataRows(spreadsheetId, mapping); - // N+1 방지: 루프 전 기존 부원 학번 Set / 사전 회원 key Set / 부원 userId Set 일괄 조회 Set existingMemberStudentNumbers = new HashSet<>(clubMemberRepository.findStudentNumbersByClubId(clubId)); Set existingPreMemberKeys = buildPreMemberKeySet(clubId); Set existingMemberUserIds = new HashSet<>(clubMemberRepository.findUserIdsByClubId(clubId)); - // 시트에 등장하는 모든 학번 수집 → users 일괄 조회 - Set allStudentNumbers = rows.stream() - .map(row -> getCell(row, mapping, SheetColumnMapping.STUDENT_ID)) - .filter(s -> !s.isBlank()) + Set allStudentNumbers = members.stream() + .map(ImportMember::studentNumber) + .filter(studentNumber -> !studentNumber.isBlank()) .collect(Collectors.toSet()); Map> usersByStudentNumber = new HashMap<>(); if (!allStudentNumbers.isEmpty()) { userRepository.findAllByUniversityIdAndStudentNumberIn(universityId, allStudentNumbers) - .forEach(u -> usersByStudentNumber - .computeIfAbsent(u.getStudentNumber(), k -> new ArrayList<>()) - .add(u)); + .forEach(user -> usersByStudentNumber + .computeIfAbsent(user.getStudentNumber(), key -> new ArrayList<>()) + .add(user)); } - // 루프에서 수집할 배치 작업 대상 + List previewMembers = new ArrayList<>(); List clubMembersToSave = new ArrayList<>(); Set studentNumbersToCleanFromPre = new HashSet<>(); List preMembersToSave = new ArrayList<>(); - - List warnings = new ArrayList<>(); + List warnings = new ArrayList<>(initialWarnings); int presidentCount = 0; - for (List row : rows) { - String name = getCell(row, mapping, SheetColumnMapping.NAME); - String studentNumber = getCell(row, mapping, SheetColumnMapping.STUDENT_ID); + for (ImportMember importMember : members) { + String name = importMember.name(); + String studentNumber = importMember.studentNumber(); + ClubPosition position = importMember.clubPosition() == null + ? ClubPosition.MEMBER + : importMember.clubPosition(); if (name.isBlank() || studentNumber.isBlank()) { continue; } - // 전화번호 형식 유효성 경고 - String phone = getCell(row, mapping, SheetColumnMapping.PHONE); - if (!phone.isBlank() && !PhoneNumberNormalizer.looksLikePhoneNumber(phone)) { - warnings.add(String.format( - "전화번호 형식이 올바르지 않습니다 - 학번: %s, 이름: %s, 입력값: '%s'", - studentNumber, name, phone - )); - } - - String positionStr = getCell(row, mapping, SheetColumnMapping.POSITION); - ClubPosition position = resolvePosition(positionStr); - - // 회장 중복 감지 if (position == ClubPosition.PRESIDENT) { presidentCount++; if (presidentCount > 1) { warnings.add(String.format( "회장이 2명 이상 등록되어 있습니다 - 중복 회장: 학번 %s, 이름 %s", - studentNumber, name + studentNumber, + name )); } } - // 이미 club_member에 있는 학번은 스킵 if (existingMemberStudentNumbers.contains(studentNumber)) { continue; } - // users 테이블에서 동일 대학 + 학번으로 매칭, 이름까지 일치하는 유저 탐색 - // trim() / equalsIgnoreCase로 공백·대소문자 차이 허용 - // 주의: existingPreMemberKeys 체크보다 먼저 수행하여 - // 이미 pre_member로 등록된 행도 User 생성 후 재-import 시 club_member로 승격 가능하게 함 List candidates = usersByStudentNumber.getOrDefault(studentNumber, List.of()); - List matched = candidates.stream() - .filter(u -> name != null && u.getName() != null - && name.trim().equalsIgnoreCase(u.getName().trim())) + List matchedUsers = candidates.stream() + .filter(user -> user.getName() != null && name.equalsIgnoreCase(user.getName().trim())) .toList(); - if (matched.size() == 1) { - User matchedUser = matched.get(0); - // userId Set으로 중복 체크 (N+1 없음) + if (matchedUsers.size() == 1) { + User matchedUser = matchedUsers.get(0); if (!existingMemberUserIds.contains(matchedUser.getId())) { - // 기존 pre_member 행도 함께 정리 (중복 방지) - studentNumbersToCleanFromPre.add(matchedUser.getStudentNumber()); - clubMembersToSave.add(ClubMember.builder() + ClubMember clubMember = ClubMember.builder() .club(club) .user(matchedUser) .clubPosition(position) - .build()); + .build(); + + clubMembersToSave.add(clubMember); + previewMembers.add(SheetImportPreviewResponse.PreviewMember.from(clubMember)); + studentNumbersToCleanFromPre.add(matchedUser.getStudentNumber()); existingMemberStudentNumbers.add(studentNumber); existingMemberUserIds.add(matchedUser.getId()); existingPreMemberKeys.remove(preMemberKey(studentNumber, name)); @@ -180,54 +300,100 @@ private SheetImportResponse importPreMembersFromSheet( continue; } - if (matched.size() > 1) { + if (matchedUsers.size() > 1) { warnings.add(String.format( "동명이인이 여러 명 존재하여 자동 매칭할 수 없습니다 - 학번: %s, 이름: %s", - studentNumber, name + studentNumber, + name )); } - // users 미매칭 또는 동명이인 → 이미 pre_member에 있으면 스킵, 없으면 등록 if (existingPreMemberKeys.contains(preMemberKey(studentNumber, name))) { continue; } - preMembersToSave.add(ClubPreMember.builder() + ClubPreMember preMember = ClubPreMember.builder() .club(club) .studentNumber(studentNumber) .name(name) .clubPosition(position) - .build()); + .build(); + + preMembersToSave.add(preMember); + previewMembers.add(SheetImportPreviewResponse.PreviewMember.from(preMember)); existingPreMemberKeys.add(preMemberKey(studentNumber, name)); } - // 배치 처리: pre_member 정리 → club_member 일괄 저장 → 채팅방 등록 - if (!studentNumbersToCleanFromPre.isEmpty()) { - clubPreMemberRepository.deleteByClubIdAndStudentNumberIn( - clubId, studentNumbersToCleanFromPre - ); - } - List savedMembers = clubMembersToSave.isEmpty() - ? List.of() - : clubMemberRepository.saveAll(clubMembersToSave); + return new SheetImportPlan( + previewMembers, + clubMembersToSave, + studentNumbersToCleanFromPre, + preMembersToSave, + warnings + ); + } + + private SheetImportSource loadSheetImportSource(String spreadsheetId, SheetColumnMapping mapping) { + List> rows = readDataRows(spreadsheetId, mapping); + List members = new ArrayList<>(); + List warnings = new ArrayList<>(); + + for (List row : rows) { + String name = getCell(row, mapping, SheetColumnMapping.NAME); + String studentNumber = getCell(row, mapping, SheetColumnMapping.STUDENT_ID); + + if (name.isBlank() || studentNumber.isBlank()) { + continue; + } + + String phone = getCell(row, mapping, SheetColumnMapping.PHONE); + if (!phone.isBlank() && !PhoneNumberNormalizer.looksLikePhoneNumber(phone)) { + warnings.add(String.format( + "전화번호 형식이 올바르지 않습니다 - 학번: %s, 이름: %s, 입력값: '%s'", + studentNumber, + name, + phone + )); + } - for (ClubMember saved : savedMembers) { - chatRoomMembershipService.addClubMember(saved); + String positionText = getCell(row, mapping, SheetColumnMapping.POSITION); + members.add(new ImportMember( + studentNumber, + name, + resolvePosition(positionText) + )); } - if (!preMembersToSave.isEmpty()) { - clubPreMemberRepository.saveAll(preMembersToSave); + return new SheetImportSource(members, warnings); + } + + private List toImportMembers(List members) { + if (members == null) { + return List.of(); } - int autoRegistered = savedMembers.size(); - int imported = preMembersToSave.size(); + return members.stream() + .filter(SheetImportConfirmRequest.ConfirmMember::isEnabled) + .map(member -> new ImportMember( + member.studentNumber().trim(), + member.name().trim(), + member.clubPosition() == null ? ClubPosition.MEMBER : member.clubPosition() + )) + .toList(); + } - log.info( - "Sheet import done. clubId={}, spreadsheetId={}, imported={}, autoRegistered={}, " - + "warnings={}", - clubId, spreadsheetId, imported, autoRegistered, warnings.size() - ); - return SheetImportResponse.of(imported, autoRegistered, warnings); + private T executeTransaction(Supplier action) { + return executeWithTemplate(false, action); + } + + private T executeReadOnlyTransaction(Supplier action) { + return executeWithTemplate(true, action); + } + + private T executeWithTemplate(boolean readOnly, Supplier action) { + TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); + transactionTemplate.setReadOnly(readOnly); + return Objects.requireNonNull(transactionTemplate.execute(status -> action.get())); } private List> readDataRows(String spreadsheetId, SheetColumnMapping mapping) { @@ -241,7 +407,6 @@ private List> readDataRows(String spreadsheetId, SheetColumnMapping List> values = response.getValues(); return values != null ? values : List.of(); - } catch (IOException e) { if (GoogleSheetApiExceptionHelper.isAccessDenied(e)) { log.warn( @@ -257,22 +422,23 @@ private List> readDataRows(String spreadsheetId, SheetColumnMapping } private String getCell(List row, SheetColumnMapping mapping, String field) { - int col = mapping.getColumnIndex(field); - if (col < 0 || col >= row.size()) { + int columnIndex = mapping.getColumnIndex(field); + if (columnIndex < 0 || columnIndex >= row.size()) { return ""; } - String value = row.get(col).toString().trim(); + + String value = row.get(columnIndex).toString().trim(); if (value.startsWith("'")) { return value.substring(1); } return value; } - private ClubPosition resolvePosition(String positionStr) { - for (ClubPosition pos : ClubPosition.values()) { - if (pos.getDescription().equals(positionStr) - || pos.name().equalsIgnoreCase(positionStr)) { - return pos; + private ClubPosition resolvePosition(String positionText) { + for (ClubPosition position : ClubPosition.values()) { + if (position.getDescription().equals(positionText) + || position.name().equalsIgnoreCase(positionText)) { + return position; } } return ClubPosition.MEMBER; @@ -281,11 +447,40 @@ private ClubPosition resolvePosition(String positionStr) { private Set buildPreMemberKeySet(Integer clubId) { Set keys = new HashSet<>(); clubPreMemberRepository.findStudentNumberAndNameByClubId(clubId) - .forEach(k -> keys.add(preMemberKey(k.getStudentNumber(), k.getName()))); + .forEach(key -> keys.add(preMemberKey(key.getStudentNumber(), key.getName()))); return keys; } private String preMemberKey(String studentNumber, String name) { return studentNumber + "\u0000" + name; } + + private record SheetImportPlan( + List previewMembers, + List clubMembersToSave, + Set studentNumbersToCleanFromPre, + List preMembersToSave, + List warnings + ) { + private int autoRegisteredCount() { + return clubMembersToSave.size(); + } + + private int preRegisteredCount() { + return preMembersToSave.size(); + } + } + + private record SheetImportSource( + List members, + List warnings + ) { + } + + private record ImportMember( + String studentNumber, + String name, + ClubPosition clubPosition + ) { + } } diff --git a/src/main/java/gg/agit/konect/domain/council/model/Council.java b/src/main/java/gg/agit/konect/domain/council/model/Council.java index 8083f4dd6..0c56d3db9 100644 --- a/src/main/java/gg/agit/konect/domain/council/model/Council.java +++ b/src/main/java/gg/agit/konect/domain/council/model/Council.java @@ -102,6 +102,8 @@ public void update( String introduce, String location, String personalColor, + String phoneNumber, + String email, String instagramUserName, String operatingHour ) { @@ -110,6 +112,8 @@ public void update( this.introduce = introduce; this.location = location; this.personalColor = personalColor; + this.phoneNumber = phoneNumber; + this.email = email; this.instagramUserName = instagramUserName; this.operatingHour = operatingHour; } diff --git a/src/main/java/gg/agit/konect/domain/council/service/CouncilService.java b/src/main/java/gg/agit/konect/domain/council/service/CouncilService.java index f00a61245..68cb47467 100644 --- a/src/main/java/gg/agit/konect/domain/council/service/CouncilService.java +++ b/src/main/java/gg/agit/konect/domain/council/service/CouncilService.java @@ -57,6 +57,8 @@ public void updateCouncil(Integer userId, CouncilUpdateRequest request) { request.introduce(), request.location(), request.personalColor(), + request.phoneNumber(), + request.email(), request.instagramUserName(), request.operatingHour() ); diff --git a/src/main/java/gg/agit/konect/domain/notice/dto/CouncilNoticeCreateRequest.java b/src/main/java/gg/agit/konect/domain/notice/dto/CouncilNoticeCreateRequest.java index 5c29f8ebc..cd8356cbb 100644 --- a/src/main/java/gg/agit/konect/domain/notice/dto/CouncilNoticeCreateRequest.java +++ b/src/main/java/gg/agit/konect/domain/notice/dto/CouncilNoticeCreateRequest.java @@ -5,16 +5,16 @@ import gg.agit.konect.domain.council.model.Council; import gg.agit.konect.domain.notice.model.CouncilNotice; import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; public record CouncilNoticeCreateRequest( - @NotEmpty(message = "공지사항 제목은 필수 입력입니다.") + @NotBlank(message = "공지사항 제목은 필수 입력입니다.") @Size(max = 255, message = "공지사항 제목은 최대 255자 입니다.") @Schema(description = "공지사항 제목", example = "동아리 박람회 참가 신청 마감 안내", requiredMode = REQUIRED) String title, - @NotEmpty(message = "공지사항 내용은 필수 입력입니다.") + @NotBlank(message = "공지사항 내용은 필수 입력입니다.") @Schema(description = "공지사항 내용", example = "2025년 동아리 박람회 참가 신청이 12월 15일까지입니다.", requiredMode = REQUIRED) String content ) { diff --git a/src/main/java/gg/agit/konect/domain/notice/dto/CouncilNoticeUpdateRequest.java b/src/main/java/gg/agit/konect/domain/notice/dto/CouncilNoticeUpdateRequest.java index d3231cb98..b0182cfd9 100644 --- a/src/main/java/gg/agit/konect/domain/notice/dto/CouncilNoticeUpdateRequest.java +++ b/src/main/java/gg/agit/konect/domain/notice/dto/CouncilNoticeUpdateRequest.java @@ -3,16 +3,16 @@ import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; public record CouncilNoticeUpdateRequest( - @NotEmpty(message = "공지사항 제목은 필수 입력입니다.") + @NotBlank(message = "공지사항 제목은 필수 입력입니다.") @Size(max = 255, message = "공지사항 제목은 최대 255자 입니다.") @Schema(description = "공지사항 제목", example = "동아리 박람회 참가 신청 마감 안내", requiredMode = REQUIRED) String title, - @NotEmpty(message = "공지사항 내용은 필수 입력입니다.") + @NotBlank(message = "공지사항 내용은 필수 입력입니다.") @Schema(description = "공지사항 내용", example = "2025년 동아리 박람회 참가 신청이 12월 15일까지입니다.", requiredMode = REQUIRED) String content ) { diff --git a/src/main/java/gg/agit/konect/domain/notice/model/CouncilNoticeReadHistory.java b/src/main/java/gg/agit/konect/domain/notice/model/CouncilNoticeReadHistory.java index 4c779229e..998c3e2e0 100644 --- a/src/main/java/gg/agit/konect/domain/notice/model/CouncilNoticeReadHistory.java +++ b/src/main/java/gg/agit/konect/domain/notice/model/CouncilNoticeReadHistory.java @@ -25,7 +25,7 @@ uniqueConstraints = { @UniqueConstraint( name = "uq_council_notice_read_history_user_id_council_notice_id", - columnNames = {"user_id, council_notice_id"} + columnNames = {"user_id", "council_notice_id"} ) } ) diff --git a/src/main/java/gg/agit/konect/domain/notification/listener/ClubApplicationNotificationListener.java b/src/main/java/gg/agit/konect/domain/notification/listener/ClubApplicationNotificationListener.java index 43143483d..dfe713c92 100644 --- a/src/main/java/gg/agit/konect/domain/notification/listener/ClubApplicationNotificationListener.java +++ b/src/main/java/gg/agit/konect/domain/notification/listener/ClubApplicationNotificationListener.java @@ -6,6 +6,7 @@ import org.springframework.transaction.event.TransactionalEventListener; import gg.agit.konect.domain.club.event.ClubApplicationApprovedEvent; +import gg.agit.konect.domain.club.event.ClubApplicationRejectedEvent; import gg.agit.konect.domain.club.event.ClubApplicationSubmittedEvent; import gg.agit.konect.domain.notification.service.NotificationService; import lombok.RequiredArgsConstructor; @@ -37,4 +38,13 @@ public void handleClubApplicationSubmitted(ClubApplicationSubmittedEvent event) ) ); } + + @TransactionalEventListener(phase = AFTER_COMMIT) + public void handleClubApplicationRejected(ClubApplicationRejectedEvent event) { + notificationService.sendClubApplicationRejectedNotification( + event.receiverId(), + event.clubId(), + event.clubName() + ); + } } diff --git a/src/main/java/gg/agit/konect/domain/studytime/event/StudyTimeAccumulatedEvent.java b/src/main/java/gg/agit/konect/domain/studytime/event/StudyTimeAccumulatedEvent.java new file mode 100644 index 000000000..99db82a01 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/studytime/event/StudyTimeAccumulatedEvent.java @@ -0,0 +1,9 @@ +package gg.agit.konect.domain.studytime.event; + +public record StudyTimeAccumulatedEvent( + Integer userId +) { + public static StudyTimeAccumulatedEvent of(Integer userId) { + return new StudyTimeAccumulatedEvent(userId); + } +} diff --git a/src/main/java/gg/agit/konect/domain/studytime/listener/StudyTimeRankingUpdateListener.java b/src/main/java/gg/agit/konect/domain/studytime/listener/StudyTimeRankingUpdateListener.java new file mode 100644 index 000000000..1145eaceb --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/studytime/listener/StudyTimeRankingUpdateListener.java @@ -0,0 +1,25 @@ +package gg.agit.konect.domain.studytime.listener; + +import static org.springframework.transaction.event.TransactionPhase.AFTER_COMMIT; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionalEventListener; + +import gg.agit.konect.domain.studytime.event.StudyTimeAccumulatedEvent; +import gg.agit.konect.domain.studytime.service.StudyTimeRankingUpdateService; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class StudyTimeRankingUpdateListener { + + private final StudyTimeRankingUpdateService studyTimeRankingUpdateService; + + @TransactionalEventListener(phase = AFTER_COMMIT) + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void handleStudyTimeAccumulated(StudyTimeAccumulatedEvent event) { + studyTimeRankingUpdateService.updateRankingsForUser(event.userId()); + } +} diff --git a/src/main/java/gg/agit/konect/domain/studytime/model/StudyTimeMonthly.java b/src/main/java/gg/agit/konect/domain/studytime/model/StudyTimeMonthly.java deleted file mode 100644 index 58c37ba55..000000000 --- a/src/main/java/gg/agit/konect/domain/studytime/model/StudyTimeMonthly.java +++ /dev/null @@ -1,70 +0,0 @@ -package gg.agit.konect.domain.studytime.model; - -import static jakarta.persistence.FetchType.LAZY; -import static jakarta.persistence.GenerationType.IDENTITY; -import static lombok.AccessLevel.PROTECTED; - -import java.time.LocalDate; - -import gg.agit.konect.domain.user.model.User; -import gg.agit.konect.global.model.BaseEntity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.Table; -import jakarta.persistence.UniqueConstraint; -import jakarta.validation.constraints.NotNull; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@Entity -@Table( - name = "study_time_monthly", - uniqueConstraints = { - @UniqueConstraint(name = "uq_study_time_monthly_user_month", columnNames = {"user_id", "study_month"}) - } -) -@NoArgsConstructor(access = PROTECTED) -public class StudyTimeMonthly extends BaseEntity { - - @Id - @GeneratedValue(strategy = IDENTITY) - @Column(name = "id", nullable = false, updatable = false, unique = true) - private Integer id; - - @ManyToOne(fetch = LAZY) - @JoinColumn(name = "user_id", nullable = false, updatable = false) - private User user; - - @NotNull - @Column(name = "study_month", nullable = false) - private LocalDate studyMonth; - - @NotNull - @Column(name = "total_seconds", nullable = false) - private Long totalSeconds; - - @Builder - private StudyTimeMonthly(User user, LocalDate studyMonth, Long totalSeconds) { - this.user = user; - this.studyMonth = studyMonth; - this.totalSeconds = totalSeconds; - } - - public static StudyTimeMonthly of(User user, LocalDate studyMonth, Long totalSeconds) { - return StudyTimeMonthly.builder() - .user(user) - .studyMonth(studyMonth) - .totalSeconds(totalSeconds) - .build(); - } - - public void addSeconds(long seconds) { - this.totalSeconds += seconds; - } -} diff --git a/src/main/java/gg/agit/konect/domain/studytime/model/StudyTimeTotal.java b/src/main/java/gg/agit/konect/domain/studytime/model/StudyTimeTotal.java deleted file mode 100644 index b79141236..000000000 --- a/src/main/java/gg/agit/konect/domain/studytime/model/StudyTimeTotal.java +++ /dev/null @@ -1,62 +0,0 @@ -package gg.agit.konect.domain.studytime.model; - -import static jakarta.persistence.FetchType.LAZY; -import static jakarta.persistence.GenerationType.IDENTITY; -import static lombok.AccessLevel.PROTECTED; - -import gg.agit.konect.domain.user.model.User; -import gg.agit.konect.global.model.BaseEntity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.OneToOne; -import jakarta.persistence.Table; -import jakarta.persistence.UniqueConstraint; -import jakarta.validation.constraints.NotNull; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@Entity -@Table( - name = "study_time_total", - uniqueConstraints = { - @UniqueConstraint(name = "uq_study_time_total_user", columnNames = {"user_id"}) - } -) -@NoArgsConstructor(access = PROTECTED) -public class StudyTimeTotal extends BaseEntity { - - @Id - @GeneratedValue(strategy = IDENTITY) - @Column(name = "id", nullable = false, updatable = false, unique = true) - private Integer id; - - @OneToOne(fetch = LAZY) - @JoinColumn(name = "user_id", nullable = false, unique = true) - private User user; - - @NotNull - @Column(name = "total_seconds", nullable = false) - private Long totalSeconds; - - @Builder - private StudyTimeTotal(User user, Long totalSeconds) { - this.user = user; - this.totalSeconds = totalSeconds; - } - - public static StudyTimeTotal of(User user, Long totalSeconds) { - return StudyTimeTotal.builder() - .user(user) - .totalSeconds(totalSeconds) - .build(); - } - - public void addSeconds(long seconds) { - this.totalSeconds += seconds; - } -} diff --git a/src/main/java/gg/agit/konect/domain/studytime/repository/StudyTimeDailyRepository.java b/src/main/java/gg/agit/konect/domain/studytime/repository/StudyTimeDailyRepository.java index f8d813df2..36bb3c687 100644 --- a/src/main/java/gg/agit/konect/domain/studytime/repository/StudyTimeDailyRepository.java +++ b/src/main/java/gg/agit/konect/domain/studytime/repository/StudyTimeDailyRepository.java @@ -28,4 +28,46 @@ List findByUserIds( ); List findAllByStudyDate(LocalDate studyDate); + + @Query(""" + SELECT COALESCE(SUM(std.totalSeconds), 0) + FROM StudyTimeDaily std + WHERE std.user.id = :userId + AND std.studyDate BETWEEN :startDate AND :endDate + """) + Long sumTotalSecondsByUserIdAndStudyDateBetween( + @Param("userId") Integer userId, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate + ); + + @Query(""" + SELECT COALESCE(SUM(std.totalSeconds), 0) + FROM StudyTimeDaily std + WHERE std.user.id = :userId + """) + Long sumTotalSecondsByUserId(@Param("userId") Integer userId); + + @Query(""" + SELECT COALESCE(SUM(std.totalSeconds), 0) + FROM StudyTimeDaily std + WHERE std.user.id IN :userIds + AND std.studyDate = :studyDate + """) + Long sumTotalSecondsByUserIdsAndStudyDate( + @Param("userIds") List userIds, + @Param("studyDate") LocalDate studyDate + ); + + @Query(""" + SELECT COALESCE(SUM(std.totalSeconds), 0) + FROM StudyTimeDaily std + WHERE std.user.id IN :userIds + AND std.studyDate BETWEEN :startDate AND :endDate + """) + Long sumTotalSecondsByUserIdsAndStudyDateBetween( + @Param("userIds") List userIds, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate + ); } diff --git a/src/main/java/gg/agit/konect/domain/studytime/repository/StudyTimeMonthlyRepository.java b/src/main/java/gg/agit/konect/domain/studytime/repository/StudyTimeMonthlyRepository.java deleted file mode 100644 index 2b1c9f66f..000000000 --- a/src/main/java/gg/agit/konect/domain/studytime/repository/StudyTimeMonthlyRepository.java +++ /dev/null @@ -1,31 +0,0 @@ -package gg.agit.konect.domain.studytime.repository; - -import java.time.LocalDate; -import java.util.List; -import java.util.Optional; - -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.Repository; -import org.springframework.data.repository.query.Param; - -import gg.agit.konect.domain.studytime.model.StudyTimeMonthly; - -public interface StudyTimeMonthlyRepository extends Repository { - - Optional findByUserIdAndStudyMonth(Integer userId, LocalDate studyMonth); - - @Query(""" - SELECT stm - FROM StudyTimeMonthly stm - WHERE stm.user.id IN :userIds - AND stm.studyMonth = :studyMonth - """) - List findByUserIds( - @Param("userIds") List userIds, - @Param("studyMonth") LocalDate studyMonth - ); - - StudyTimeMonthly save(StudyTimeMonthly studyTimeMonthly); - - List findAllByStudyMonth(LocalDate studyMonth); -} diff --git a/src/main/java/gg/agit/konect/domain/studytime/repository/StudyTimeTotalRepository.java b/src/main/java/gg/agit/konect/domain/studytime/repository/StudyTimeTotalRepository.java deleted file mode 100644 index 2ca6aae92..000000000 --- a/src/main/java/gg/agit/konect/domain/studytime/repository/StudyTimeTotalRepository.java +++ /dev/null @@ -1,14 +0,0 @@ -package gg.agit.konect.domain.studytime.repository; - -import java.util.Optional; - -import org.springframework.data.repository.Repository; - -import gg.agit.konect.domain.studytime.model.StudyTimeTotal; - -public interface StudyTimeTotalRepository extends Repository { - - Optional findByUserId(Integer userId); - - StudyTimeTotal save(StudyTimeTotal studyTimeTotal); -} diff --git a/src/main/java/gg/agit/konect/domain/studytime/scheduler/StudyTimeScheduler.java b/src/main/java/gg/agit/konect/domain/studytime/scheduler/StudyTimeScheduler.java index 9f3a0541b..b2a4e18c2 100644 --- a/src/main/java/gg/agit/konect/domain/studytime/scheduler/StudyTimeScheduler.java +++ b/src/main/java/gg/agit/konect/domain/studytime/scheduler/StudyTimeScheduler.java @@ -1,7 +1,5 @@ package gg.agit.konect.domain.studytime.scheduler; -import static java.util.concurrent.TimeUnit.SECONDS; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Scheduled; @@ -18,39 +16,6 @@ public class StudyTimeScheduler { private final StudyTimeSchedulerService studyTimeSchedulerService; - @Scheduled(fixedDelay = 5, timeUnit = SECONDS) - public void updateClubStudyTimeRanking() { - try { - SCHEDULER_LOGGER.info("동아리 공부 시간 랭킹 업데이트 시작"); - studyTimeSchedulerService.updateClubStudyTimeRanking(); - SCHEDULER_LOGGER.info("동아리 공부 시간 랭킹 업데이트 완료"); - } catch (Exception e) { - SCHEDULER_LOGGER.error("동아리 공부 시간 랭킹 업데이트 과정에서 오류가 발생했습니다.", e); - } - } - - @Scheduled(fixedDelay = 5, timeUnit = SECONDS) - public void updatePersonalStudyTimeRanking() { - try { - SCHEDULER_LOGGER.info("개인 공부 시간 랭킹 업데이트 시작"); - studyTimeSchedulerService.updatePersonalStudyTimeRanking(); - SCHEDULER_LOGGER.info("개인 공부 시간 랭킹 업데이트 완료"); - } catch (Exception e) { - SCHEDULER_LOGGER.error("개인 공부 시간 랭킹 업데이트 과정에서 오류가 발생했습니다.", e); - } - } - - @Scheduled(fixedDelay = 5, timeUnit = SECONDS) - public void updateStudentNumberStudyTimeRanking() { - try { - SCHEDULER_LOGGER.info("학번별 공부 시간 랭킹 업데이트 시작"); - studyTimeSchedulerService.updateStudentNumberStudyTimeRanking(); - SCHEDULER_LOGGER.info("학번별 공부 시간 랭킹 업데이트 완료"); - } catch (Exception e) { - SCHEDULER_LOGGER.error("학번별 공부 시간 랭킹 업데이트 과정에서 오류가 발생했습니다.", e); - } - } - @Scheduled(cron = "0 0 0 * * *") public void resetStudyTimeRankingDaily() { try { diff --git a/src/main/java/gg/agit/konect/domain/studytime/service/StudyTimeQueryService.java b/src/main/java/gg/agit/konect/domain/studytime/service/StudyTimeQueryService.java index f98d33881..a2130d778 100644 --- a/src/main/java/gg/agit/konect/domain/studytime/service/StudyTimeQueryService.java +++ b/src/main/java/gg/agit/konect/domain/studytime/service/StudyTimeQueryService.java @@ -7,11 +7,7 @@ import gg.agit.konect.domain.studytime.dto.StudyTimeSummaryResponse; import gg.agit.konect.domain.studytime.model.StudyTimeDaily; -import gg.agit.konect.domain.studytime.model.StudyTimeMonthly; -import gg.agit.konect.domain.studytime.model.StudyTimeTotal; import gg.agit.konect.domain.studytime.repository.StudyTimeDailyRepository; -import gg.agit.konect.domain.studytime.repository.StudyTimeMonthlyRepository; -import gg.agit.konect.domain.studytime.repository.StudyTimeTotalRepository; import lombok.RequiredArgsConstructor; @Service @@ -20,8 +16,6 @@ public class StudyTimeQueryService { private final StudyTimeDailyRepository studyTimeDailyRepository; - private final StudyTimeMonthlyRepository studyTimeMonthlyRepository; - private final StudyTimeTotalRepository studyTimeTotalRepository; public StudyTimeSummaryResponse getSummary(Integer userId) { Long dailyStudyTime = getDailyStudyTime(userId); @@ -32,9 +26,7 @@ public StudyTimeSummaryResponse getSummary(Integer userId) { } public long getTotalStudyTime(Integer userId) { - return studyTimeTotalRepository.findByUserId(userId) - .map(StudyTimeTotal::getTotalSeconds) - .orElse(0L); + return studyTimeDailyRepository.sumTotalSecondsByUserId(userId); } public long getDailyStudyTime(Integer userId) { @@ -46,10 +38,9 @@ public long getDailyStudyTime(Integer userId) { } public long getMonthlyStudyTime(Integer userId) { - LocalDate month = LocalDate.now().withDayOfMonth(1); + LocalDate today = LocalDate.now(); + LocalDate monthStart = today.withDayOfMonth(1); - return studyTimeMonthlyRepository.findByUserIdAndStudyMonth(userId, month) - .map(StudyTimeMonthly::getTotalSeconds) - .orElse(0L); + return studyTimeDailyRepository.sumTotalSecondsByUserIdAndStudyDateBetween(userId, monthStart, today); } } diff --git a/src/main/java/gg/agit/konect/domain/studytime/service/StudyTimeRankingUpdateService.java b/src/main/java/gg/agit/konect/domain/studytime/service/StudyTimeRankingUpdateService.java new file mode 100644 index 000000000..77c1b3cf3 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/studytime/service/StudyTimeRankingUpdateService.java @@ -0,0 +1,152 @@ +package gg.agit.konect.domain.studytime.service; + +import static gg.agit.konect.domain.studytime.model.RankingType.*; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import gg.agit.konect.domain.club.model.ClubMember; +import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.studytime.model.RankingType; +import gg.agit.konect.domain.studytime.model.StudyTimeRanking; +import gg.agit.konect.domain.studytime.repository.RankingTypeRepository; +import gg.agit.konect.domain.studytime.repository.StudyTimeDailyRepository; +import gg.agit.konect.domain.studytime.repository.StudyTimeRankingRepository; +import gg.agit.konect.domain.university.model.University; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class StudyTimeRankingUpdateService { + + private final UserRepository userRepository; + private final ClubMemberRepository clubMemberRepository; + private final StudyTimeDailyRepository studyTimeDailyRepository; + private final StudyTimeRankingRepository studyTimeRankingRepository; + private final RankingTypeRepository rankingTypeRepository; + + @Transactional + public void updateRankingsForUser(Integer userId) { + User user = userRepository.getById(userId); + + updatePersonalRanking(user); + updateClubRankings(user); + updateStudentNumberRanking(user); + } + + private void updatePersonalRanking(User user) { + RankingType rankingType = rankingTypeRepository.getByNameIgnoreCase(RANKING_TYPE_PERSONAL); + University university = user.getUniversity(); + + Long dailySeconds = getDailySeconds(user.getId()); + Long monthlySeconds = getMonthlySeconds(user.getId()); + + Optional ranking = studyTimeRankingRepository.findRanking( + rankingType.getId(), university.getId(), user.getId() + ); + + if (ranking.isEmpty()) { + studyTimeRankingRepository.save( + StudyTimeRanking.of(rankingType, university, user.getId(), user.getName(), dailySeconds, monthlySeconds) + ); + } else { + ranking.get().updateSeconds(dailySeconds, monthlySeconds); + } + } + + private void updateClubRankings(User user) { + RankingType rankingType = rankingTypeRepository.getByNameIgnoreCase(RANKING_TYPE_CLUB); + List clubMembers = clubMemberRepository.findByUserId(user.getId()); + + for (ClubMember clubMember : clubMembers) { + Integer clubId = clubMember.getClub().getId(); + String clubName = clubMember.getClub().getName(); + University university = clubMember.getClub().getUniversity(); + + List memberUserIds = clubMemberRepository.findUserIdsByClubId(clubId); + + Long dailySeconds = getAggregatedDailySeconds(memberUserIds); + Long monthlySeconds = getAggregatedMonthlySeconds(memberUserIds); + + Optional ranking = studyTimeRankingRepository.findRanking( + rankingType.getId(), university.getId(), clubId + ); + + if (ranking.isEmpty()) { + studyTimeRankingRepository.save( + StudyTimeRanking.of(rankingType, university, clubId, clubName, dailySeconds, monthlySeconds) + ); + } else { + ranking.get().updateSeconds(dailySeconds, monthlySeconds); + } + } + } + + private void updateStudentNumberRanking(User user) { + RankingType rankingType = rankingTypeRepository.getByNameIgnoreCase(RANKING_TYPE_STUDENT_NUMBER); + University university = user.getUniversity(); + String studentNumberYear = user.getStudentNumberYear(); + + List userIds = userRepository.findAllByUniversityIdAndStudentNumberStartingWith( + university.getId(), studentNumberYear + ).stream().map(User::getId).toList(); + + Long dailySeconds = getAggregatedDailySeconds(userIds); + Long monthlySeconds = getAggregatedMonthlySeconds(userIds); + + Optional ranking = studyTimeRankingRepository.findRankingByName( + rankingType.getId(), university.getId(), studentNumberYear + ); + + if (ranking.isEmpty()) { + Integer maxTargetId = studyTimeRankingRepository.findMaxTargetId( + rankingType.getId(), university.getId() + ); + Integer nextTargetId = maxTargetId + 1; + + studyTimeRankingRepository.save( + StudyTimeRanking.of( + rankingType, university, nextTargetId, + studentNumberYear, dailySeconds, monthlySeconds + ) + ); + } else { + ranking.get().updateSeconds(dailySeconds, monthlySeconds); + } + } + + private Long getDailySeconds(Integer userId) { + LocalDate today = LocalDate.now(); + return studyTimeDailyRepository.sumTotalSecondsByUserIdAndStudyDateBetween(userId, today, today); + } + + private Long getMonthlySeconds(Integer userId) { + LocalDate today = LocalDate.now(); + LocalDate monthStart = today.withDayOfMonth(1); + return studyTimeDailyRepository.sumTotalSecondsByUserIdAndStudyDateBetween(userId, monthStart, today); + } + + private Long getAggregatedDailySeconds(List userIds) { + if (userIds.isEmpty()) { + return 0L; + } + LocalDate today = LocalDate.now(); + return studyTimeDailyRepository.sumTotalSecondsByUserIdsAndStudyDate(userIds, today); + } + + private Long getAggregatedMonthlySeconds(List userIds) { + if (userIds.isEmpty()) { + return 0L; + } + LocalDate today = LocalDate.now(); + LocalDate monthStart = today.withDayOfMonth(1); + return studyTimeDailyRepository.sumTotalSecondsByUserIdsAndStudyDateBetween(userIds, monthStart, today); + } +} diff --git a/src/main/java/gg/agit/konect/domain/studytime/service/StudyTimeSchedulerService.java b/src/main/java/gg/agit/konect/domain/studytime/service/StudyTimeSchedulerService.java index 879c9c7e5..b8c8f9288 100644 --- a/src/main/java/gg/agit/konect/domain/studytime/service/StudyTimeSchedulerService.java +++ b/src/main/java/gg/agit/konect/domain/studytime/service/StudyTimeSchedulerService.java @@ -1,32 +1,12 @@ package gg.agit.konect.domain.studytime.service; -import static gg.agit.konect.domain.studytime.model.RankingType.*; -import static java.util.stream.Collectors.*; - -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.HashSet; import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.stream.Stream; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import gg.agit.konect.domain.club.model.ClubMember; -import gg.agit.konect.domain.club.repository.ClubMemberRepository; -import gg.agit.konect.domain.studytime.model.RankingType; -import gg.agit.konect.domain.studytime.model.StudyTimeDaily; -import gg.agit.konect.domain.studytime.model.StudyTimeMonthly; import gg.agit.konect.domain.studytime.model.StudyTimeRanking; -import gg.agit.konect.domain.studytime.repository.RankingTypeRepository; -import gg.agit.konect.domain.studytime.repository.StudyTimeDailyRepository; -import gg.agit.konect.domain.studytime.repository.StudyTimeMonthlyRepository; import gg.agit.konect.domain.studytime.repository.StudyTimeRankingRepository; -import gg.agit.konect.domain.university.model.University; -import gg.agit.konect.domain.user.model.User; import lombok.RequiredArgsConstructor; @Service @@ -34,226 +14,7 @@ @Transactional(readOnly = true) public class StudyTimeSchedulerService { - private final ClubMemberRepository clubMemberRepository; - private final StudyTimeDailyRepository studyTimeDailyRepository; - private final StudyTimeMonthlyRepository studyTimeMonthlyRepository; private final StudyTimeRankingRepository studyTimeRankingRepository; - private final RankingTypeRepository rankingTypeRepository; - - @Transactional - public void updateClubStudyTimeRanking() { - LocalDate today = LocalDate.now(); - LocalDate currentMonth = today.withDayOfMonth(1); - - List studyTimeDailies = studyTimeDailyRepository.findAllByStudyDate(today); - List studyTimeMonthlies = studyTimeMonthlyRepository.findAllByStudyMonth(currentMonth); - if (studyTimeDailies.isEmpty() && studyTimeMonthlies.isEmpty()) { - return; - } - - RankingType rankingType = rankingTypeRepository.getByNameIgnoreCase(RANKING_TYPE_CLUB); - - Set userIds = Stream.concat( - studyTimeDailies.stream().map(studyTimeDaily -> studyTimeDaily.getUser().getId()), - studyTimeMonthlies.stream().map(studyTimeMonthly -> studyTimeMonthly.getUser().getId()) - ) - .collect(toSet()); - - Map> userToClubMembersMap = clubMemberRepository.findByUserIdIn( - new ArrayList<>(userIds)).stream() - .collect(groupingBy(clubMember -> clubMember.getId().getUserId())); - - Map clubDailyMap = studyTimeDailies.stream() - .flatMap(studyTimeDaily -> userToClubMembersMap - .getOrDefault(studyTimeDaily.getUser().getId(), List.of()) - .stream() - .map(clubMember -> Map.entry(UniversityClub.of(clubMember), studyTimeDaily.getTotalSeconds())) - ) - .collect(groupingBy(Map.Entry::getKey, summingLong(Map.Entry::getValue))); - Map clubMonthlyMap = studyTimeMonthlies.stream() - .flatMap(studyTimeMonthly -> userToClubMembersMap - .getOrDefault(studyTimeMonthly.getUser().getId(), List.of()) - .stream() - .map(clubMember -> Map.entry(UniversityClub.of(clubMember), studyTimeMonthly.getTotalSeconds())) - ) - .collect(groupingBy(Map.Entry::getKey, summingLong(Map.Entry::getValue))); - - Set universityClubs = Stream.concat( - clubDailyMap.keySet().stream(), - clubMonthlyMap.keySet().stream() - ) - .collect(toSet()); - - for (UniversityClub universityClub : universityClubs) { - Long dailySeconds = clubDailyMap.getOrDefault(universityClub, 0L); - Long monthlySeconds = clubMonthlyMap.getOrDefault(universityClub, 0L); - - Optional studyTimeRanking = studyTimeRankingRepository.findRanking( - rankingType.getId(), - universityClub.university().getId(), - universityClub.clubId() - ); - - if (studyTimeRanking.isEmpty()) { - studyTimeRankingRepository.save( - StudyTimeRanking.of( - rankingType, - universityClub.university(), - universityClub.clubId(), - universityClub.clubName(), - dailySeconds, - monthlySeconds - ) - ); - } else { - studyTimeRanking.get().updateSeconds(dailySeconds, monthlySeconds); - } - } - } - - @Transactional - public void updatePersonalStudyTimeRanking() { - LocalDate today = LocalDate.now(); - LocalDate currentMonth = today.withDayOfMonth(1); - - List studyTimeDailies = studyTimeDailyRepository.findAllByStudyDate(today); - List studyTimeMonthlies = studyTimeMonthlyRepository.findAllByStudyMonth(currentMonth); - if (studyTimeDailies.isEmpty() && studyTimeMonthlies.isEmpty()) { - return; - } - - RankingType rankingType = rankingTypeRepository.getByNameIgnoreCase(RANKING_TYPE_PERSONAL); - - Map userDailySecondsMap = studyTimeDailies.stream() - .collect(toMap( - studyTimeDaily -> studyTimeDaily.getUser().getId(), - StudyTimeDaily::getTotalSeconds - )); - Map userMonthlySecondsMap = studyTimeMonthlies.stream() - .collect(toMap( - studyTimeMonthly -> studyTimeMonthly.getUser().getId(), - StudyTimeMonthly::getTotalSeconds - )); - - List users = Stream.concat( - studyTimeDailies.stream().map(StudyTimeDaily::getUser), - studyTimeMonthlies.stream().map(StudyTimeMonthly::getUser) - ) - .distinct() - .toList(); - - for (User user : users) { - Integer userId = user.getId(); - University university = user.getUniversity(); - - Long dailySeconds = userDailySecondsMap.getOrDefault(userId, 0L); - Long monthlySeconds = userMonthlySecondsMap.getOrDefault(userId, 0L); - - Optional studyTimeRanking = studyTimeRankingRepository.findRanking( - rankingType.getId(), university.getId(), userId - ); - - if (studyTimeRanking.isEmpty()) { - studyTimeRankingRepository.save( - StudyTimeRanking.of( - rankingType, - university, - userId, - user.getName(), - dailySeconds, - monthlySeconds - ) - ); - } else { - studyTimeRanking.get().updateSeconds(dailySeconds, monthlySeconds); - } - } - } - - @Transactional - public void updateStudentNumberStudyTimeRanking() { - LocalDate today = LocalDate.now(); - LocalDate currentMonth = today.withDayOfMonth(1); - - List studyTimeDailies = studyTimeDailyRepository.findAllByStudyDate(today); - List studyTimeMonthlies = studyTimeMonthlyRepository.findAllByStudyMonth(currentMonth); - if (studyTimeDailies.isEmpty() && studyTimeMonthlies.isEmpty()) { - return; - } - - RankingType rankingType = rankingTypeRepository.getByNameIgnoreCase(RANKING_TYPE_STUDENT_NUMBER); - - Map universityYearDailyMap = studyTimeDailies.stream() - .collect(groupingBy( - studyTimeDaily -> UniversityYear.of(studyTimeDaily.getUser()), - summingLong(StudyTimeDaily::getTotalSeconds) - )); - Map universityYearMonthlyMap = studyTimeMonthlies.stream() - .collect(groupingBy( - studyTimeMonthly -> UniversityYear.of(studyTimeMonthly.getUser()), - summingLong(StudyTimeMonthly::getTotalSeconds) - )); - - Set universityYears = new HashSet<>(); - universityYears.addAll(universityYearDailyMap.keySet()); - universityYears.addAll(universityYearMonthlyMap.keySet()); - - for (UniversityYear universityYear : universityYears) { - Long dailySeconds = universityYearDailyMap.getOrDefault(universityYear, 0L); - Long monthlySeconds = universityYearMonthlyMap.getOrDefault(universityYear, 0L); - - Optional studyTimeRanking = studyTimeRankingRepository.findRankingByName( - rankingType.getId(), universityYear.university().getId(), universityYear.year() - ); - - if (studyTimeRanking.isEmpty()) { - Integer maxTargetId = studyTimeRankingRepository.findMaxTargetId( - rankingType.getId(), - universityYear.university().getId() - ); - Integer nextTargetId = maxTargetId + 1; - - studyTimeRankingRepository.save( - StudyTimeRanking.of( - rankingType, - universityYear.university(), - nextTargetId, - universityYear.year(), - dailySeconds, - monthlySeconds - ) - ); - } else { - studyTimeRanking.get().updateSeconds(dailySeconds, monthlySeconds); - } - } - } - - private record UniversityClub( - University university, - Integer clubId, - String clubName - ) { - public static UniversityClub of(ClubMember clubMember) { - return new UniversityClub( - clubMember.getClub().getUniversity(), - clubMember.getClub().getId(), - clubMember.getClub().getName() - ); - } - } - - private record UniversityYear( - University university, - String year - ) { - public static UniversityYear of(User user) { - return new UniversityYear( - user.getUniversity(), - user.getStudentNumberYear() - ); - } - } @Transactional public void resetStudyTimeRankingDaily() { diff --git a/src/main/java/gg/agit/konect/domain/studytime/service/StudyTimerService.java b/src/main/java/gg/agit/konect/domain/studytime/service/StudyTimerService.java index 64f8ba762..13f6ba2ae 100644 --- a/src/main/java/gg/agit/konect/domain/studytime/service/StudyTimerService.java +++ b/src/main/java/gg/agit/konect/domain/studytime/service/StudyTimerService.java @@ -7,6 +7,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -14,14 +15,11 @@ import gg.agit.konect.domain.studytime.dto.StudyTimerStopRequest; import gg.agit.konect.domain.studytime.dto.StudyTimerStopResponse; import gg.agit.konect.domain.studytime.dto.StudyTimerSyncRequest; +import gg.agit.konect.domain.studytime.event.StudyTimeAccumulatedEvent; import gg.agit.konect.domain.studytime.model.StudyTimeDaily; -import gg.agit.konect.domain.studytime.model.StudyTimeMonthly; import gg.agit.konect.domain.studytime.model.StudyTimeSummary; -import gg.agit.konect.domain.studytime.model.StudyTimeTotal; import gg.agit.konect.domain.studytime.model.StudyTimer; import gg.agit.konect.domain.studytime.repository.StudyTimeDailyRepository; -import gg.agit.konect.domain.studytime.repository.StudyTimeMonthlyRepository; -import gg.agit.konect.domain.studytime.repository.StudyTimeTotalRepository; import gg.agit.konect.domain.studytime.repository.StudyTimerRepository; import gg.agit.konect.domain.user.model.User; import gg.agit.konect.domain.user.repository.UserRepository; @@ -39,10 +37,9 @@ public class StudyTimerService { private final StudyTimeQueryService studyTimeQueryService; private final StudyTimerRepository studyTimerRepository; private final StudyTimeDailyRepository studyTimeDailyRepository; - private final StudyTimeMonthlyRepository studyTimeMonthlyRepository; - private final StudyTimeTotalRepository studyTimeTotalRepository; private final UserRepository userRepository; private final EntityManager entityManager; + private final ApplicationEventPublisher eventPublisher; @Transactional public void start(Integer userId) { @@ -74,7 +71,8 @@ public StudyTimerStopResponse stop(Integer userId, StudyTimerStopRequest request deleteTimerIfElapsedTimeInvalid(studyTimer, serverSeconds, clientSeconds); - accumulateStudyTime(studyTimer.getUser(), lastSyncedAt, endedAt); + accumulateDailySeconds(studyTimer.getUser(), lastSyncedAt, endedAt); + eventPublisher.publishEvent(StudyTimeAccumulatedEvent.of(userId)); studyTimerRepository.delete(studyTimer); StudyTimeSummary summary = buildSummary(userId, serverSeconds); @@ -94,18 +92,13 @@ public void sync(Integer userId, StudyTimerSyncRequest request) { deleteTimerIfElapsedTimeInvalid(studyTimer, serverSeconds, clientSeconds); - accumulateStudyTime(studyTimer.getUser(), lastSyncedAt, syncedAt); + accumulateDailySeconds(studyTimer.getUser(), lastSyncedAt, syncedAt); + eventPublisher.publishEvent(StudyTimeAccumulatedEvent.of(userId)); studyTimer.updateStartedAt(syncedAt); } - private void accumulateStudyTime(User user, LocalDateTime startedAt, LocalDateTime endedAt) { - long sessionSeconds = accumulateDailyAndMonthlySeconds(user, startedAt, endedAt); - updateTotalSecondsIfNeeded(user, sessionSeconds); - } - - private long accumulateDailyAndMonthlySeconds(User user, LocalDateTime startedAt, LocalDateTime endedAt) { + private void accumulateDailySeconds(User user, LocalDateTime startedAt, LocalDateTime endedAt) { LocalDateTime cursor = startedAt; - long sessionSeconds = 0L; LocalDate endDate = endedAt.toLocalDate(); while (cursor.isBefore(endedAt)) { @@ -117,29 +110,24 @@ private long accumulateDailyAndMonthlySeconds(User user, LocalDateTime startedAt segmentEnd = endedAt; } - sessionSeconds += accumulateDailyAndMonthlySegment(user, cursor, segmentEnd); + accumulateDailySegment(user, cursor, segmentEnd); cursor = segmentEnd; } - - return sessionSeconds; } - private long accumulateDailyAndMonthlySegment(User user, LocalDateTime segmentStart, LocalDateTime segmentEnd) { + private void accumulateDailySegment(User user, LocalDateTime segmentStart, LocalDateTime segmentEnd) { if (!segmentStart.isBefore(segmentEnd)) { - return 0L; + return; } long seconds = Duration.between(segmentStart, segmentEnd).getSeconds(); if (seconds <= 0) { - return 0L; + return; } LocalDate date = segmentStart.toLocalDate(); addDailySegment(user, date, seconds); - addMonthlySegment(user, date, seconds); - - return seconds; } private void addDailySegment(User user, LocalDate date, long seconds) { @@ -151,31 +139,6 @@ private void addDailySegment(User user, LocalDate date, long seconds) { studyTimeDailyRepository.save(daily); } - private void addMonthlySegment(User user, LocalDate date, long seconds) { - LocalDate month = date.withDayOfMonth(1); - - StudyTimeMonthly monthly = studyTimeMonthlyRepository - .findByUserIdAndStudyMonth(user.getId(), month) - .orElseGet(() -> StudyTimeMonthly.of(user, month, 0L)); - - monthly.addSeconds(seconds); - studyTimeMonthlyRepository.save(monthly); - } - - private void updateTotalSecondsIfNeeded(User user, long sessionSeconds) { - if (sessionSeconds > 0) { - addTotalSeconds(user, sessionSeconds); - } - } - - private void addTotalSeconds(User user, long seconds) { - StudyTimeTotal total = studyTimeTotalRepository.findByUserId(user.getId()) - .orElseGet(() -> StudyTimeTotal.of(user, 0L)); - - total.addSeconds(seconds); - studyTimeTotalRepository.save(total); - } - private StudyTimeSummary buildSummary(Integer userId, long sessionSeconds) { long dailySeconds = studyTimeQueryService.getDailyStudyTime(userId); long monthlySeconds = studyTimeQueryService.getMonthlyStudyTime(userId); diff --git a/src/main/java/gg/agit/konect/domain/upload/controller/UploadController.java b/src/main/java/gg/agit/konect/domain/upload/controller/UploadController.java index a031b2331..8ae2cb68e 100644 --- a/src/main/java/gg/agit/konect/domain/upload/controller/UploadController.java +++ b/src/main/java/gg/agit/konect/domain/upload/controller/UploadController.java @@ -8,6 +8,7 @@ import gg.agit.konect.domain.upload.enums.UploadTarget; import gg.agit.konect.domain.upload.service.UploadService; import gg.agit.konect.global.auth.annotation.UserId; +import gg.agit.konect.global.ratelimit.annotation.RateLimit; import lombok.RequiredArgsConstructor; @RestController @@ -16,6 +17,7 @@ public class UploadController implements UploadApi { private final UploadService uploadService; + @RateLimit(maxRequests = 20, timeWindowSeconds = 60, keyExpression = "#userId") @Override public ResponseEntity uploadImage( @UserId Integer userId, diff --git a/src/main/java/gg/agit/konect/domain/upload/service/UploadService.java b/src/main/java/gg/agit/konect/domain/upload/service/UploadService.java index a7445a37e..8934adf8f 100644 --- a/src/main/java/gg/agit/konect/domain/upload/service/UploadService.java +++ b/src/main/java/gg/agit/konect/domain/upload/service/UploadService.java @@ -3,6 +3,7 @@ import java.io.IOException; import java.io.InputStream; import java.time.LocalDate; +import java.util.Locale; import java.util.Set; import java.util.UUID; @@ -43,7 +44,7 @@ public ImageUploadResponse uploadImage(MultipartFile file, UploadTarget target) validateS3Configuration(); validateFile(file); - String contentType = file.getContentType(); + String contentType = normalizeContentType(file.getContentType()); String extension = getExtension(contentType); String key = buildKey(extension, target); @@ -90,11 +91,16 @@ private void validateFile(MultipartFile file) { throw CustomException.of(ApiResponseCode.INVALID_REQUEST_BODY); } - String contentType = file.getContentType(); - if (contentType == null || contentType.isBlank() || !ALLOWED_CONTENT_TYPES.contains(contentType)) { - throw CustomException.of(ApiResponseCode.INVALID_FILE_CONTENT_TYPE); + if (s3StorageProperties.maxUploadBytes() != null + && s3StorageProperties.maxUploadBytes() > 0 + && file.getSize() > s3StorageProperties.maxUploadBytes()) { + throw CustomException.of(ApiResponseCode.PAYLOAD_TOO_LARGE); } + String contentType = normalizeContentType(file.getContentType()); + if (contentType == null || !ALLOWED_CONTENT_TYPES.contains(contentType)) { + throw CustomException.of(ApiResponseCode.INVALID_FILE_CONTENT_TYPE); + } } private String buildKey(String extension, UploadTarget target) { @@ -131,8 +137,18 @@ private String normalizePrefix(String keyPrefix) { return normalized; } + private String normalizeContentType(String contentType) { + if (contentType == null || contentType.isBlank()) { + return null; + } + return contentType.trim().toLowerCase(Locale.ROOT); + } + private String getExtension(String contentType) { - return switch (contentType.toLowerCase()) { + if (contentType == null) { + return "bin"; + } + return switch (contentType) { case "image/png" -> "png"; case "image/jpg", "image/jpeg" -> "jpg"; case "image/webp" -> "webp"; @@ -147,8 +163,12 @@ private String trimTrailingSlash(String baseUrl) { String trimmed = baseUrl.trim(); - if (trimmed.endsWith("/")) { - return trimmed.substring(0, trimmed.length() - 1); + while (trimmed.endsWith("/")) { + trimmed = trimmed.substring(0, trimmed.length() - 1); + } + + if (trimmed.isEmpty()) { + throw CustomException.of(ApiResponseCode.ILLEGAL_STATE, "storage.cdn.base-url 설정이 필요합니다."); } return trimmed; diff --git a/src/main/java/gg/agit/konect/domain/user/repository/UserRepository.java b/src/main/java/gg/agit/konect/domain/user/repository/UserRepository.java index 86dc810f9..8b6840d2a 100644 --- a/src/main/java/gg/agit/konect/domain/user/repository/UserRepository.java +++ b/src/main/java/gg/agit/konect/domain/user/repository/UserRepository.java @@ -55,6 +55,18 @@ List findAllByUniversityIdAndStudentNumberIn( @Param("studentNumbers") Set studentNumbers ); + @Query(""" + SELECT u + FROM User u + WHERE u.university.id = :universityId + AND u.studentNumber LIKE CONCAT(:studentNumberYear, '%') + AND u.deletedAt IS NULL + """) + List findAllByUniversityIdAndStudentNumberStartingWith( + @Param("universityId") Integer universityId, + @Param("studentNumberYear") String studentNumberYear + ); + User save(User user); @Query(""" diff --git a/src/main/java/gg/agit/konect/domain/user/service/SignupTokenService.java b/src/main/java/gg/agit/konect/domain/user/service/SignupTokenService.java index eada25d9c..2d250ffcd 100644 --- a/src/main/java/gg/agit/konect/domain/user/service/SignupTokenService.java +++ b/src/main/java/gg/agit/konect/domain/user/service/SignupTokenService.java @@ -1,10 +1,8 @@ package gg.agit.konect.domain.user.service; import java.time.Duration; -import java.util.List; import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; @@ -26,14 +24,6 @@ public class SignupTokenService { private static final int EXPECTED_PARTS_WITHOUT_NAME = 3; private static final int EXPECTED_PARTS_WITH_NAME = 4; - private static final DefaultRedisScript GET_DEL_SCRIPT = - new DefaultRedisScript<>( - "local v = redis.call('GET', KEYS[1]); " + - "if v then redis.call('DEL', KEYS[1]); end; " + - "return v;", - String.class - ); - private final StringRedisTemplate redis; private final SecureTokenGenerator secureTokenGenerator; @@ -76,7 +66,7 @@ public SignupClaims consumeOrThrow(String token) { throw CustomException.of(ApiResponseCode.INVALID_SIGNUP_TOKEN); } - String value = redis.execute(GET_DEL_SCRIPT, List.of(key(token))); + String value = redis.opsForValue().getAndDelete(key(token)); SignupClaims claims = deserialize(value); if (claims == null) { throw CustomException.of(ApiResponseCode.INVALID_SIGNUP_TOKEN); diff --git a/src/main/java/gg/agit/konect/domain/version/model/Version.java b/src/main/java/gg/agit/konect/domain/version/model/Version.java index 264ea4897..578385e18 100644 --- a/src/main/java/gg/agit/konect/domain/version/model/Version.java +++ b/src/main/java/gg/agit/konect/domain/version/model/Version.java @@ -24,7 +24,7 @@ uniqueConstraints = { @UniqueConstraint( name = "uq_version_platform_version", - columnNames = {"platform, version"} + columnNames = {"platform", "version"} ) } ) diff --git a/src/main/java/gg/agit/konect/global/auth/oauth/NativeSessionBridgeService.java b/src/main/java/gg/agit/konect/global/auth/oauth/NativeSessionBridgeService.java index f1890c052..fa1603286 100644 --- a/src/main/java/gg/agit/konect/global/auth/oauth/NativeSessionBridgeService.java +++ b/src/main/java/gg/agit/konect/global/auth/oauth/NativeSessionBridgeService.java @@ -1,11 +1,9 @@ package gg.agit.konect.global.auth.oauth; import java.time.Duration; -import java.util.List; import java.util.Optional; import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.lang.Nullable; import org.springframework.stereotype.Service; @@ -18,13 +16,6 @@ public class NativeSessionBridgeService { private static final String KEY_PREFIX = "native:session-bridge:"; private static final Duration TTL = Duration.ofSeconds(30); - private static final DefaultRedisScript GET_DEL_SCRIPT = - new DefaultRedisScript<>( - "local v = redis.call('GET', KEYS[1]); " + - "if v then redis.call('DEL', KEYS[1]); end; " + - "return v;", - String.class - ); private final StringRedisTemplate redis; private final SecureTokenGenerator secureTokenGenerator; @@ -46,7 +37,7 @@ public Optional consume(@Nullable String token) { } String key = KEY_PREFIX + token; - String value = redis.execute(GET_DEL_SCRIPT, List.of(key)); + String value = redis.opsForValue().getAndDelete(key); if (value == null || value.isBlank()) { return Optional.empty(); diff --git a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java index d62a0fdfa..b8530ba10 100644 --- a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java +++ b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java @@ -49,6 +49,8 @@ public enum ApiResponseCode { INVALID_NOTIFICATION_TOKEN(HttpStatus.BAD_REQUEST, "푸시 알림 토큰이 유효하지 않습니다."), FEE_PAYMENT_IMAGE_REQUIRED(HttpStatus.BAD_REQUEST, "회비 납부가 필요한 동아리입니다. 납부 증빙 사진을 첨부해주세요."), AMBIGUOUS_USER_MATCH(HttpStatus.BAD_REQUEST, "동일한 정보로 식별되는 사용자가 2명 이상입니다. 관리자에게 문의해주세요."), + CLUB_SHEET_ANALYSIS_REQUIRED(HttpStatus.BAD_REQUEST, + "구글 시트 파일에서 동아리 부원을 가져오기 전에 먼저 AI 분석 및 등록이 완료되어야 합니다."), // 401 Unauthorized INVALID_SESSION(HttpStatus.UNAUTHORIZED, "올바르지 않은 인증 정보 입니다."), @@ -117,6 +119,7 @@ public enum ApiResponseCode { ALREADY_CLUB_MEMBER(HttpStatus.CONFLICT, "이미 동아리 회원입니다."), ALREADY_CLUB_PRE_MEMBER(HttpStatus.CONFLICT, "이미 동아리에 사전 등록된 회원입니다."), DUPLICATE_CLUB_APPLY_QUESTION(HttpStatus.CONFLICT, "중복된 가입 문항이 포함되어 있습니다."), + ALREADY_PROCESSED_CLUB_APPLY(HttpStatus.CONFLICT, "이미 처리된 동아리 가입 신청입니다."), VICE_PRESIDENT_ALREADY_EXISTS(HttpStatus.CONFLICT, "부회장은 이미 존재합니다."), ALREADY_RUNNING_STUDY_TIMER(HttpStatus.CONFLICT, "이미 실행 중인 스터디 타이머가 있습니다."), ALREADY_EXIST_CLUB_RECRUITMENT(HttpStatus.CONFLICT, "이미 동아리 모집 공고가 존재합니다."), @@ -128,6 +131,9 @@ public enum ApiResponseCode { // 413 Payload Too Large (요청 본문 크기 초과) PAYLOAD_TOO_LARGE(HttpStatus.PAYLOAD_TOO_LARGE, "파일 크기가 제한을 초과했습니다."), + // 429 Too Many Requests (요청 횟수 초과) + TOO_MANY_REQUESTS(HttpStatus.TOO_MANY_REQUESTS, "요청 횟수가 너무 많습니다. 잠시 후 다시 시도해주세요."), + // 500 Internal Server Error (서버 오류) CLIENT_ABORTED(HttpStatus.INTERNAL_SERVER_ERROR, "클라이언트에 의해 연결이 중단되었습니다."), FAILED_UPLOAD_FILE(HttpStatus.INTERNAL_SERVER_ERROR, "파일 업로드에 실패했습니다."), diff --git a/src/main/java/gg/agit/konect/global/config/SchedulingConfig.java b/src/main/java/gg/agit/konect/global/config/SchedulingConfig.java index d98b0c6ba..4fd5364a8 100644 --- a/src/main/java/gg/agit/konect/global/config/SchedulingConfig.java +++ b/src/main/java/gg/agit/konect/global/config/SchedulingConfig.java @@ -1,10 +1,16 @@ package gg.agit.konect.global.config; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.annotation.EnableScheduling; @Configuration @EnableScheduling +@ConditionalOnProperty( + value = "app.scheduling.enabled", + havingValue = "true", + matchIfMissing = true +) public class SchedulingConfig { } diff --git a/src/main/java/gg/agit/konect/global/ratelimit/annotation/RateLimit.java b/src/main/java/gg/agit/konect/global/ratelimit/annotation/RateLimit.java new file mode 100644 index 000000000..2bcef7605 --- /dev/null +++ b/src/main/java/gg/agit/konect/global/ratelimit/annotation/RateLimit.java @@ -0,0 +1,35 @@ +package gg.agit.konect.global.ratelimit.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Rate Limit을 적용하기 위한 어노테이션. + * 메서드 또는 클래스 레벨에 적용할 수 있습니다. + */ +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface RateLimit { + + /** + * 허용할 최대 요청 횟수. + * 기본값: 50 + */ + int maxRequests() default 50; + + /** + * 시간 윈도우 (초 단위). + * 기본값: 600 (10분) + */ + int timeWindowSeconds() default 600; + + /** + * Rate Limit 키를 생성할 SpEL 표현식. + * 메서드 파라미터를 참조할 수 있습니다. 예: #userId, #target + * 기본값: 빈 문자열 (메서드 시그니처 기본 키 사용) + */ + String keyExpression() default ""; + +} diff --git a/src/main/java/gg/agit/konect/global/ratelimit/aspect/RateLimitAspect.java b/src/main/java/gg/agit/konect/global/ratelimit/aspect/RateLimitAspect.java new file mode 100644 index 000000000..2cfa24ed8 --- /dev/null +++ b/src/main/java/gg/agit/konect/global/ratelimit/aspect/RateLimitAspect.java @@ -0,0 +1,117 @@ +package gg.agit.konect.global.ratelimit.aspect; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import gg.agit.konect.global.ratelimit.annotation.RateLimit; +import gg.agit.konect.global.ratelimit.exception.RateLimitExceededException; +import lombok.extern.slf4j.Slf4j; + +import java.util.Collections; + +@Slf4j +@Aspect +@Component +public class RateLimitAspect { + + private static final String RATE_LIMIT_KEY_PREFIX = "ratelimit:"; + + // Lua 스크립트: INCR로 원자적 증가 후, 처음 생성된 경우에만 TTL 설정 + // 이 방식으로 SETNX와 INCR 사이의 레이스 컨디션을 방지 + private static final String INCR_WITH_TTL_SCRIPT = + "local current = redis.call('INCR', KEYS[1]) " + + "if current == 1 then " + + " redis.call('EXPIRE', KEYS[1], ARGV[1]) " + + "end " + + "return current"; + + private final StringRedisTemplate redisTemplate; + private final SpelExpressionParser parser = new SpelExpressionParser(); + + // Lua 스크립트를 재사용하기 위해 미리 컴파일 (매 요청마다 생성 비용 제거) + private final DefaultRedisScript incrWithTtlScript; + + public RateLimitAspect(StringRedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + this.incrWithTtlScript = new DefaultRedisScript<>(INCR_WITH_TTL_SCRIPT, Long.class); + } + + @Around("@within(rateLimit) || @annotation(rateLimit)") + public Object around(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable { + String key = generateKey(joinPoint, rateLimit); + int maxRequests = rateLimit.maxRequests(); + int timeWindowSeconds = rateLimit.timeWindowSeconds(); + + // Redis 장애 시 fail-open 정책: 예외 발생하면 rate limit 체크를 스킵하고 요청 처리 + long currentCount; + try { + // 미리 생성해둔 Lua 스크립트 실행 (원자적 INCR + TTL 설정) + Long count = redisTemplate.execute( + incrWithTtlScript, + Collections.singletonList(key), + String.valueOf(timeWindowSeconds) + ); + currentCount = count != null ? count : 0; + } catch (Exception e) { + log.warn("Rate limiting Redis operation failed for key={}: {}. Skipping rate limit check.", + key, e.getMessage()); + return joinPoint.proceed(); + } + + // 제한 초과 확인 - 초과 시에만 TTL 조회 + if (currentCount > maxRequests) { + Long remainingSeconds = redisTemplate.getExpire(key); + long remaining = remainingSeconds != null && remainingSeconds > 0 + ? remainingSeconds + : timeWindowSeconds; + throw RateLimitExceededException.withRemainingTime(remaining); + } + + return joinPoint.proceed(); + } + + private String generateKey(ProceedingJoinPoint joinPoint, RateLimit rateLimit) { + String keyExpression = rateLimit.keyExpression(); + MethodSignature signature = (MethodSignature)joinPoint.getSignature(); + String methodKey = signature.getDeclaringTypeName() + "." + signature.getName(); + + if (!StringUtils.hasText(keyExpression)) { + return RATE_LIMIT_KEY_PREFIX + methodKey; + } + + StandardEvaluationContext context = new StandardEvaluationContext(); + String[] paramNames = signature.getParameterNames(); + Object[] args = joinPoint.getArgs(); + + // -parameters 플래그 없이 컴파일된 경우 paramNames가 null일 수 있음 + // 이 경우 arg0, arg1, ... 형태의 플레이스홀더 이름 생성 + if (paramNames == null) { + paramNames = new String[args.length]; + for (int i = 0; i < args.length; i++) { + paramNames[i] = "arg" + i; + } + } + + for (int i = 0; i < paramNames.length; i++) { + context.setVariable(paramNames[i], args[i]); + } + + try { + Object result = parser.parseExpression(keyExpression).getValue(context); + String keyValue = result != null ? result.toString() : "unknown"; + return RATE_LIMIT_KEY_PREFIX + methodKey + ":" + keyValue; + } catch (Exception e) { + log.error("SpEL expression evaluation failed for keyExpression='{}', methodKey='{}': {}", + keyExpression, methodKey, e.getMessage()); + return RATE_LIMIT_KEY_PREFIX + methodKey; + } + } +} diff --git a/src/main/java/gg/agit/konect/global/ratelimit/exception/RateLimitExceededException.java b/src/main/java/gg/agit/konect/global/ratelimit/exception/RateLimitExceededException.java new file mode 100644 index 000000000..8fccdd64e --- /dev/null +++ b/src/main/java/gg/agit/konect/global/ratelimit/exception/RateLimitExceededException.java @@ -0,0 +1,31 @@ +package gg.agit.konect.global.ratelimit.exception; + +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; + +/** + * Rate Limit 초과 시 발생하는 예외 팩토리. + * CustomException은 상속이 불가능하므로 팩토리 메서드로 생성합니다. + */ +public final class RateLimitExceededException { + + private static final String MESSAGE_TEMPLATE = "요청 횟수가 너무 많습니다. %d초 후 다시 시도해주세요."; + + private RateLimitExceededException() { + // 유틸리티 클래스 + } + + /** + * 남은 시간(초)을 포함하여 예외를 생성합니다. + * + * @param remainingSeconds 남은 시간(초) + * @return CustomException + */ + public static CustomException withRemainingTime(long remainingSeconds) { + return CustomException.of( + ApiResponseCode.TOO_MANY_REQUESTS, + String.format(MESSAGE_TEMPLATE, remainingSeconds) + ); + } + +} diff --git a/src/main/java/gg/agit/konect/infrastructure/oauth/GoogleDriveOAuthService.java b/src/main/java/gg/agit/konect/infrastructure/oauth/GoogleDriveOAuthService.java index 2e5d6133f..69d479ce4 100644 --- a/src/main/java/gg/agit/konect/infrastructure/oauth/GoogleDriveOAuthService.java +++ b/src/main/java/gg/agit/konect/infrastructure/oauth/GoogleDriveOAuthService.java @@ -1,11 +1,9 @@ package gg.agit.konect.infrastructure.oauth; import java.time.Duration; -import java.util.List; import java.util.Map; import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; @@ -41,11 +39,6 @@ public class GoogleDriveOAuthService { private static final Duration STATE_TTL = Duration.ofMinutes(10); private static final String CALLBACK_PATH = "/auth/oauth/google/drive/callback"; - private static final DefaultRedisScript GET_DEL_SCRIPT = new DefaultRedisScript<>( - "local v = redis.call('GET', KEYS[1]); if v then redis.call('DEL', KEYS[1]); end; return v;", - String.class - ); - private final GoogleSheetsProperties googleSheetsProperties; private final UserOAuthAccountRepository userOAuthAccountRepository; private final RestTemplate restTemplate; @@ -78,7 +71,7 @@ public void exchangeAndSaveToken(String code, String state) { } String stateKey = STATE_KEY_PREFIX + state; - String userIdStr = redis.execute(GET_DEL_SCRIPT, List.of(stateKey)); + String userIdStr = redis.opsForValue().getAndDelete(stateKey); if (userIdStr == null || userIdStr.isBlank()) { log.warn("Invalid or expired Drive OAuth state. state={}", state); diff --git a/src/main/resources/application-db.yml b/src/main/resources/application-db.yml index de2bb656e..b5a0b1fe7 100644 --- a/src/main/resources/application-db.yml +++ b/src/main/resources/application-db.yml @@ -25,6 +25,9 @@ spring: batch_size: 100 order_inserts: true order_updates: true + query: + plan_cache_max_size: 64 + plan_parameter_metadata_max_size: 32 hibernate: ddl-auto: validate open-in-view: false diff --git a/src/main/resources/db/migration/V67__backfill_missing_chat_room_members.sql b/src/main/resources/db/migration/V67__backfill_missing_chat_room_members.sql new file mode 100644 index 000000000..dfaf379ef --- /dev/null +++ b/src/main/resources/db/migration/V67__backfill_missing_chat_room_members.sql @@ -0,0 +1,23 @@ +-- 누락된 chat_room_member 데이터를 복구합니다. +-- 과거 버그로 인해 club_member는 있지만 chat_room_member가 없는 경우를 복구합니다. +-- last_read_at은 동아리 가입 시점(club_member.created_at)으로 설정합니다. + +INSERT INTO chat_room_member ( + chat_room_id, + user_id, + last_read_at, + created_at, + updated_at +) +SELECT + cr.id AS chat_room_id, + cm.user_id AS user_id, + cm.created_at AS last_read_at, + NOW() AS created_at, + NOW() AS updated_at +FROM club_member cm +JOIN chat_room cr ON cr.club_id = cm.club_id +LEFT JOIN chat_room_member crm + ON crm.chat_room_id = cr.id + AND crm.user_id = cm.user_id +WHERE crm.chat_room_id IS NULL; diff --git a/src/main/resources/db/migration/V68__remove_admin_members_from_system_admin_rooms.sql b/src/main/resources/db/migration/V68__remove_admin_members_from_system_admin_rooms.sql new file mode 100644 index 000000000..274f83007 --- /dev/null +++ b/src/main/resources/db/migration/V68__remove_admin_members_from_system_admin_rooms.sql @@ -0,0 +1,16 @@ +-- SYSTEM_ADMIN(1번)이 있는 DIRECT 채팅방에서 다른 어드민 멤버십 제거 +-- 이유: 어드민이 멤버로 추가되면 findByTwoUsers에서 해당 방을 찾지 못해 중복 생성됨 +-- 참고: https://github.com/BCSDLab/KONECT_BACK_END/issues/503 + +DELETE crm +FROM chat_room_member AS crm +JOIN users AS u + ON u.id = crm.user_id +JOIN chat_room AS cr + ON cr.id = crm.chat_room_id +JOIN chat_room_member AS system_member + ON system_member.chat_room_id = crm.chat_room_id +WHERE u.role = 'ADMIN' + AND u.id <> 1 + AND cr.room_type = 'DIRECT' + AND system_member.user_id = 1; diff --git a/src/main/resources/db/migration/V69__merge_duplicate_direct_chat_rooms.sql b/src/main/resources/db/migration/V69__merge_duplicate_direct_chat_rooms.sql new file mode 100644 index 000000000..f2647fd27 --- /dev/null +++ b/src/main/resources/db/migration/V69__merge_duplicate_direct_chat_rooms.sql @@ -0,0 +1,276 @@ +-- DIRECT 타입 채팅방 중 같은 두 유저 간 중복된 방 병합 +-- 이유: findByTwoUsers 쿼리가 유니크 결과를 기대하지만 중복 방으로 인해 2개 이상 반환됨 +-- 해결: 메시지를 하나의 방으로 합치고 중복 방 제거 +-- +SET @OLD_SQL_MODE = @@SESSION.sql_mode; +SET SESSION sql_mode = TRIM(BOTH ',' FROM REPLACE(REPLACE(REPLACE(REPLACE(CONCAT(',', @@SESSION.sql_mode, ','), ',NO_ZERO_DATE,', ','), ',NO_ZERO_IN_DATE,', ','), ',STRICT_TRANS_TABLES,', ','), ',STRICT_ALL_TABLES,', ',')); + +-- [엣지케이스 처리] +-- 1. 재시도 시 매핑 테이블 보호: NOT EXISTS로 기존 매핑 유지 +-- 2. 연산자 우선순위: WHERE 조건에 괄호로 묶어 AND/OR 우선순위 명확화 +-- 3. chat_room_member 병합: visible_message_from, left_at, last_read_at, custom_room_name, is_owner 모두 처리 +-- - 기존 멤버 UPDATE 후 GROUP BY로 집계하여 orphan INSERT (다중 loser 시 PK 충돌 방지) +-- 4. notification_mute_setting 충돌: 여러 loser 방의 설정을 GROUP BY로 집계 후 INSERT +-- - 동일 사용자가 여러 loser 방에 뮤트 설정 시 MAX(is_muted)로 병합 +-- 5. 메시지 중복 방지: from_room_id는 PK라 자연스럽게 중복 없음 +-- 6. 롤백 지원: 매핑 테이블 DROP/CREATE 대신 재사용 패턴 + +-- 임시 테이블 대신 실제 테이블 사용 (MySQL 임시 테이블 재참조 제한 회피) +-- 재시도 가능하도록: 기존 매핑 테이블이 있으면 스킵, 없으면 생성 +DROP TABLE IF EXISTS temp_direct_room_pairs; + +-- 이전 실행에서 남은 매핑 테이블이 있으면 재사용 (재시도 시) +-- 없으면 새로 생성 +CREATE TABLE IF NOT EXISTS temp_duplicate_room_map ( + from_room_id INT PRIMARY KEY, + keep_room_id INT NOT NULL, + user1_id INT NOT NULL, + user2_id INT NOT NULL +); + +-- 1) DIRECT 방 중 "정확히 2명"인 방만 유저쌍 단위로 펼침 +-- 또는 이미 매핑 테이블에 있는 방도 포함 (재시도 시 0명이 된 방 처리) +-- 주의: AND/OR 우선순위 때문에 조건을 명확히 괄호로 묶음 +-- LEFT JOIN 사용: 0명 방도 EXISTS 조건으로 포함되도록 보존 +CREATE TABLE temp_direct_room_pairs AS +SELECT + cr.id AS room_id, + LEAST(c1.user_id, c2.user_id) AS user1_id, + GREATEST(c1.user_id, c2.user_id) AS user2_id, + cr.created_at, + ( + SELECT MAX(cm.created_at) + FROM chat_message cm + WHERE cm.chat_room_id = cr.id + ) AS last_message_at +FROM chat_room cr +LEFT JOIN chat_room_member c1 + ON c1.chat_room_id = cr.id +LEFT JOIN chat_room_member c2 + ON c2.chat_room_id = cr.id + AND c1.user_id < c2.user_id +WHERE cr.room_type = 'DIRECT' + AND ( + ( + SELECT COUNT(*) + FROM chat_room_member m + WHERE m.chat_room_id = cr.id + ) = 2 + OR EXISTS ( + SELECT 1 FROM temp_duplicate_room_map existing + WHERE existing.from_room_id = cr.id OR existing.keep_room_id = cr.id + ) + ); + +-- 2) 중복 방 중 어떤 방을 남기고 어떤 방을 지울지 매핑 테이블 생성 +-- 매핑 테이블이 비어있는 경우에만 채움 (재시도 시 기존 매핑 유지) +-- ON DUPLICATE KEY UPDATE 제거: 재시도 시 매핑 방향이 뒤바뀌지 않도록 보호 +INSERT INTO temp_duplicate_room_map (from_room_id, keep_room_id, user1_id, user2_id) +SELECT + loser.room_id AS from_room_id, + winner.room_id AS keep_room_id, + loser.user1_id, + loser.user2_id +FROM ( + SELECT + room_id, + user1_id, + user2_id, + ROW_NUMBER() OVER ( + PARTITION BY user1_id, user2_id + ORDER BY + (last_message_at IS NOT NULL) DESC, + last_message_at DESC, + created_at DESC, + room_id DESC + ) AS rn, + COUNT(*) OVER ( + PARTITION BY user1_id, user2_id + ) AS room_count + FROM temp_direct_room_pairs +) loser +JOIN ( + SELECT + room_id, + user1_id, + user2_id, + ROW_NUMBER() OVER ( + PARTITION BY user1_id, user2_id + ORDER BY + (last_message_at IS NOT NULL) DESC, + last_message_at DESC, + created_at DESC, + room_id DESC + ) AS rn + FROM temp_direct_room_pairs +) winner + ON winner.user1_id = loser.user1_id + AND winner.user2_id = loser.user2_id + AND winner.rn = 1 +WHERE loser.room_count > 1 + AND loser.rn > 1 + AND NOT EXISTS (SELECT 1 FROM temp_duplicate_room_map); + +-- 3) 삭제 대상 방의 메시지를 keep 방으로 이동 +-- 메시지는 PK(id)로 관리되므로 중복 키 충돌 없음 +UPDATE chat_message cm +JOIN temp_duplicate_room_map m + ON cm.chat_room_id = m.from_room_id +SET cm.chat_room_id = m.keep_room_id, + cm.updated_at = cm.updated_at; + +-- 4) 삭제 대상 방의 멤버십 상태를 keep 방으로 병합 +-- visible_message_from: 더 이른 값(더 많은 메시지 조회 가능) 선택 +-- left_at: 둘 중 하나라도 나간 경우 나간 것으로 처리 (더 이른 값 선택) +-- last_read_at: 더 나중에 읽은 값(최신 읽음 시점) 선택 +-- custom_room_name: 사용자가 설정한 방 이름이 있으면 보존 +-- is_owner: 하나라도 owner면 owner 유지 (OR 조건) +-- 여러 loser 방이 같은 keep 방으로 매핑될 수 있으므로 먼저 집계하여 중복 업데이트 방지 + +-- 4a) 기존 keep_room 멤버 업데이트 +-- LEAST/GREATEST는 인자 중 NULL이 있으면 결과도 NULL이 되고, zero date('0000-00-00')도 문제 발생 +UPDATE chat_room_member t +JOIN ( + SELECT + m.keep_room_id, + crm.user_id, + NULLIF(MIN(crm.visible_message_from), '0000-00-00 00:00:00') AS min_visible_from, + NULLIF(MIN(crm.left_at), '0000-00-00 00:00:00') AS min_left_at, + NULLIF(MAX(crm.last_read_at), '0000-00-00 00:00:00') AS max_last_read_at, + MAX(crm.custom_room_name) AS max_custom_room_name, + MAX(CASE WHEN crm.is_owner THEN 1 ELSE 0 END) AS max_is_owner + FROM temp_duplicate_room_map m + JOIN chat_room_member crm ON crm.chat_room_id = m.from_room_id + GROUP BY m.keep_room_id, crm.user_id +) la ON t.chat_room_id = la.keep_room_id AND t.user_id = la.user_id +SET t.visible_message_from = CASE + WHEN t.visible_message_from IS NULL THEN la.min_visible_from + WHEN la.min_visible_from IS NULL THEN t.visible_message_from + ELSE LEAST(t.visible_message_from, la.min_visible_from) + END, + t.left_at = CASE + WHEN t.left_at IS NULL THEN la.min_left_at + WHEN la.min_left_at IS NULL THEN t.left_at + ELSE LEAST(t.left_at, la.min_left_at) + END, + t.last_read_at = CASE + WHEN t.last_read_at IS NULL THEN la.max_last_read_at + WHEN la.max_last_read_at IS NULL THEN t.last_read_at + ELSE GREATEST(t.last_read_at, la.max_last_read_at) + END, + t.custom_room_name = COALESCE(t.custom_room_name, la.max_custom_room_name), + t.is_owner = (t.is_owner OR la.max_is_owner > 0), + t.updated_at = t.updated_at; + +-- 4b) keep_room에 없는 loser 멤버 INSERT (orphan member 처리) +-- 여러 loser 방이 같은 keep 방으로 매핑될 수 있으므로 GROUP BY로 집계하여 PK 충돌 방지 +INSERT INTO chat_room_member ( + chat_room_id, user_id, last_read_at, created_at, updated_at, + visible_message_from, left_at, custom_room_name, is_owner +) +SELECT + m.keep_room_id, + crm.user_id, + NULLIF(MAX(crm.last_read_at), '0000-00-00 00:00:00') AS last_read_at, + MIN(crm.created_at) AS created_at, + MAX(crm.updated_at) AS updated_at, + NULLIF(MIN(crm.visible_message_from), '0000-00-00 00:00:00') AS visible_message_from, + NULLIF(MIN(crm.left_at), '0000-00-00 00:00:00') AS left_at, + MAX(crm.custom_room_name) AS custom_room_name, + MAX(CASE WHEN crm.is_owner THEN 1 ELSE 0 END) AS is_owner +FROM temp_duplicate_room_map m +JOIN chat_room_member crm ON crm.chat_room_id = m.from_room_id +LEFT JOIN chat_room_member existing + ON existing.chat_room_id = m.keep_room_id + AND existing.user_id = crm.user_id +WHERE existing.chat_room_id IS NULL +GROUP BY m.keep_room_id, crm.user_id; + +-- 5) 삭제 대상 방의 알림 뮤트 설정을 keep 방으로 이동 +-- 여러 loser 방이 같은 keep 방으로 매핑될 수 있으므로 먼저 집계하여 UNIQUE 충돌 방지 + +-- 5a) loser 방들의 뮤트 설정을 keep_room_id 기준으로 집계 +-- 동일 사용자가 여러 loser 방에 뮤트 설정을 가진 경우 MAX(is_muted)로 병합 +CREATE TEMPORARY TABLE temp_mute_setting_agg AS +SELECT + m.keep_room_id, + nms.user_id, + MAX(nms.is_muted) AS is_muted +FROM temp_duplicate_room_map m +JOIN notification_mute_setting nms + ON nms.target_id = m.from_room_id + AND nms.target_type = 'CHAT_ROOM' +GROUP BY m.keep_room_id, nms.user_id; + +-- 5b) 집계된 설정과 충돌하는 기존 keep 방 뮤트 설정 삭제 +DELETE nms +FROM notification_mute_setting nms +JOIN temp_mute_setting_agg agg + ON nms.target_id = agg.keep_room_id + AND nms.user_id = agg.user_id +WHERE nms.target_type = 'CHAT_ROOM'; + +-- 5c) 집계된 뮤트 설정을 keep 방에 INSERT +INSERT INTO notification_mute_setting (user_id, target_type, target_id, is_muted, created_at, updated_at) +SELECT + agg.user_id, + 'CHAT_ROOM', + agg.keep_room_id, + agg.is_muted, + NOW(), + NOW() +FROM temp_mute_setting_agg agg; + +-- 5d) loser 방의 뮤트 설정 삭제 (이미 집계되어 이동 완료) +DELETE nms +FROM notification_mute_setting nms +JOIN temp_duplicate_room_map m + ON nms.target_id = m.from_room_id +WHERE nms.target_type = 'CHAT_ROOM'; + +-- 임시 테이블 정리 +DROP TEMPORARY TABLE IF EXISTS temp_mute_setting_agg; + +-- 6) 삭제 대상 방의 멤버십 삭제 +DELETE crm +FROM chat_room_member crm +JOIN temp_duplicate_room_map m + ON crm.chat_room_id = m.from_room_id; + +-- 7) 삭제 대상 방 삭제 +DELETE cr +FROM chat_room cr +JOIN temp_duplicate_room_map m + ON cr.id = m.from_room_id; + +-- 8) 남은 방의 last_message_content와 last_message_sent_at 갱신 +-- MAX(created_at)으로 최신 메시지 선택, 동일 타임스탬프 시 id로 타임브레이커 +UPDATE chat_room cr +JOIN temp_duplicate_room_map m + ON cr.id = m.keep_room_id +LEFT JOIN ( + SELECT + cm1.chat_room_id, + cm1.content, + cm1.created_at + FROM chat_message cm1 + JOIN ( + SELECT chat_room_id, MAX(created_at) AS max_created_at + FROM chat_message + GROUP BY chat_room_id + ) cm2 ON cm2.chat_room_id = cm1.chat_room_id AND cm2.max_created_at = cm1.created_at + WHERE cm1.id = ( + SELECT MAX(id) + FROM chat_message + WHERE chat_room_id = cm1.chat_room_id + AND created_at = cm2.max_created_at + ) +) latest_msg ON latest_msg.chat_room_id = cr.id +SET cr.last_message_content = latest_msg.content, + cr.last_message_sent_at = latest_msg.created_at; + +-- 9) 임시 테이블 정리 +DROP TABLE IF EXISTS temp_duplicate_room_map; +DROP TABLE IF EXISTS temp_direct_room_pairs; + +SET SESSION sql_mode = @OLD_SQL_MODE; diff --git a/src/test/java/gg/agit/konect/domain/club/service/ClubMemberSheetServiceTest.java b/src/test/java/gg/agit/konect/domain/club/service/ClubMemberSheetServiceTest.java deleted file mode 100644 index 7db589d73..000000000 --- a/src/test/java/gg/agit/konect/domain/club/service/ClubMemberSheetServiceTest.java +++ /dev/null @@ -1,79 +0,0 @@ -package gg.agit.konect.domain.club.service; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.verify; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.mockito.InjectMocks; -import org.mockito.Mock; - -import com.fasterxml.jackson.databind.ObjectMapper; - -import gg.agit.konect.domain.club.dto.ClubMemberSheetSyncResponse; -import gg.agit.konect.domain.club.enums.ClubSheetSortKey; -import gg.agit.konect.domain.club.model.Club; -import gg.agit.konect.domain.club.repository.ClubMemberRepository; -import gg.agit.konect.domain.club.repository.ClubPreMemberRepository; -import gg.agit.konect.domain.club.repository.ClubRepository; -import gg.agit.konect.support.ServiceTestSupport; -import gg.agit.konect.support.fixture.ClubFixture; -import gg.agit.konect.support.fixture.UniversityFixture; - -class ClubMemberSheetServiceTest extends ServiceTestSupport { - - @Mock - private ClubRepository clubRepository; - - @Mock - private ClubMemberRepository clubMemberRepository; - - @Mock - private ClubPreMemberRepository clubPreMemberRepository; - - @Mock - private ClubPermissionValidator clubPermissionValidator; - - @Mock - private SheetSyncExecutor sheetSyncExecutor; - - @Mock - private SheetHeaderMapper sheetHeaderMapper; - - @Mock - private ObjectMapper objectMapper; - - @InjectMocks - private ClubMemberSheetService clubMemberSheetService; - - @Test - @DisplayName("시트 동기화 수에 사전 회원도 포함한다") - void syncMembersToSheetIncludesPreMembersInCount() { - // given - Integer clubId = 1; - Integer requesterId = 2; - String spreadsheetId = "spreadsheet-id"; - Club club = ClubFixture.create(UniversityFixture.create()); - club.updateGoogleSheetId(spreadsheetId); - - given(clubRepository.getById(clubId)).willReturn(club); - given(clubMemberRepository.countByClubId(clubId)).willReturn(2L); - given(clubPreMemberRepository.countByClubId(clubId)).willReturn(3L); - - // when - ClubMemberSheetSyncResponse response = clubMemberSheetService.syncMembersToSheet( - clubId, - requesterId, - ClubSheetSortKey.POSITION, - true - ); - - // then - verify(clubPermissionValidator).validateManagerAccess(clubId, requesterId); - verify(sheetSyncExecutor).executeWithSort(clubId, ClubSheetSortKey.POSITION, true); - assertThat(response.syncedMemberCount()).isEqualTo(5); - assertThat(response.sheetUrl()) - .isEqualTo("https://docs.google.com/spreadsheets/d/" + spreadsheetId + "/edit"); - } -} diff --git a/src/test/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedServiceTest.java b/src/test/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedServiceTest.java index 700737c68..e8b3ae3c0 100644 --- a/src/test/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedServiceTest.java +++ b/src/test/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedServiceTest.java @@ -3,6 +3,7 @@ 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.willThrow; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.verifyNoInteractions; @@ -43,7 +44,6 @@ class ClubSheetIntegratedServiceTest extends ServiceTestSupport { @Test @DisplayName("시트 분석 등록 후 사전 회원 가져오기를 순서대로 실행한다") void analyzeAndImportPreMembersSuccess() { - // given Integer clubId = 1; Integer requesterId = 2; String spreadsheetUrl = @@ -53,25 +53,20 @@ void analyzeAndImportPreMembersSuccess() { new SheetHeaderMapper.SheetAnalysisResult(SheetColumnMapping.defaultMapping(), null, null); SheetImportResponse expected = SheetImportResponse.of(3, 1, List.of("warn")); - given(googleSheetPermissionService.tryGrantServiceAccountWriterAccess(requesterId, spreadsheetId)) - .willReturn(true); given(sheetHeaderMapper.analyzeAllSheets(spreadsheetId)).willReturn(analysis); given(sheetImportService.importPreMembersFromSheet( clubId, requesterId, spreadsheetId, analysis.memberListMapping() - )) - .willReturn(expected); + )).willReturn(expected); - // when SheetImportResponse actual = clubSheetIntegratedService.analyzeAndImportPreMembers( clubId, requesterId, spreadsheetUrl ); - // then InOrder inOrder = inOrder( clubPermissionValidator, googleSheetPermissionService, @@ -99,9 +94,29 @@ void analyzeAndImportPreMembersSuccess() { } @Test - @DisplayName("자동 권한 부여가 실패해도 기존 공유 권한으로 가져오기를 계속 시도한다") - void analyzeAndImportPreMembersContinuesWhenAutoGrantFails() { - // given + @DisplayName("자동 권한 부여 중 예외가 발생하면 후속 시트 작업을 진행하지 않는다") + void analyzeAndImportPreMembersStopsWhenAutoGrantThrowsException() { + Integer clubId = 1; + Integer requesterId = 2; + String spreadsheetUrl = + "https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms/edit"; + String spreadsheetId = "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms"; + CustomException expected = CustomException.of(ApiResponseCode.FAILED_INIT_GOOGLE_DRIVE); + + willThrow(expected).given(googleSheetPermissionService) + .tryGrantServiceAccountWriterAccess(requesterId, spreadsheetId); + + assertThatThrownBy(() -> clubSheetIntegratedService.analyzeAndImportPreMembers( + clubId, + requesterId, + spreadsheetUrl + )).isSameAs(expected); + verifyNoInteractions(sheetHeaderMapper, clubMemberSheetService, sheetImportService); + } + + @Test + @DisplayName("요청자 Drive OAuth가 없어도 시트 분석과 가져오기를 계속 진행한다") + void analyzeAndImportPreMembersContinuesWhenGoogleDriveOAuthIsNotConnected() { Integer clubId = 1; Integer requesterId = 2; String spreadsheetUrl = @@ -109,7 +124,7 @@ void analyzeAndImportPreMembersContinuesWhenAutoGrantFails() { String spreadsheetId = "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms"; SheetHeaderMapper.SheetAnalysisResult analysis = new SheetHeaderMapper.SheetAnalysisResult(SheetColumnMapping.defaultMapping(), null, null); - SheetImportResponse expected = SheetImportResponse.of(1, 0, List.of()); + SheetImportResponse expected = SheetImportResponse.of(2, 0, List.of()); given(googleSheetPermissionService.tryGrantServiceAccountWriterAccess(requesterId, spreadsheetId)) .willReturn(false); @@ -119,17 +134,14 @@ void analyzeAndImportPreMembersContinuesWhenAutoGrantFails() { requesterId, spreadsheetId, analysis.memberListMapping() - )) - .willReturn(expected); + )).willReturn(expected); - // when SheetImportResponse actual = clubSheetIntegratedService.analyzeAndImportPreMembers( clubId, requesterId, spreadsheetUrl ); - // then InOrder inOrder = inOrder( clubPermissionValidator, googleSheetPermissionService, @@ -155,28 +167,4 @@ void analyzeAndImportPreMembersContinuesWhenAutoGrantFails() { ); assertThat(actual).isEqualTo(expected); } - - @Test - @DisplayName("자동 권한 부여 중 예외가 발생하면 후속 시트 작업을 진행하지 않는다") - void analyzeAndImportPreMembersStopsWhenAutoGrantThrowsException() { - // given - Integer clubId = 1; - Integer requesterId = 2; - String spreadsheetUrl = - "https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms/edit"; - String spreadsheetId = "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms"; - CustomException expected = CustomException.of(ApiResponseCode.FAILED_INIT_GOOGLE_DRIVE); - - given(googleSheetPermissionService.tryGrantServiceAccountWriterAccess(requesterId, spreadsheetId)) - .willThrow(expected); - - // when & then - assertThatThrownBy(() -> clubSheetIntegratedService.analyzeAndImportPreMembers( - clubId, - requesterId, - spreadsheetUrl - )) - .isSameAs(expected); - verifyNoInteractions(sheetHeaderMapper, clubMemberSheetService, sheetImportService); - } } diff --git a/src/test/java/gg/agit/konect/domain/club/service/GoogleApiTestUtils.java b/src/test/java/gg/agit/konect/domain/club/service/GoogleApiTestUtils.java index 2c2603cb7..dd2454e33 100644 --- a/src/test/java/gg/agit/konect/domain/club/service/GoogleApiTestUtils.java +++ b/src/test/java/gg/agit/konect/domain/club/service/GoogleApiTestUtils.java @@ -7,11 +7,12 @@ import com.google.api.client.http.HttpHeaders; import com.google.api.client.http.HttpResponseException; -final class GoogleApiTestUtils { +public final class GoogleApiTestUtils { - private GoogleApiTestUtils() {} + private GoogleApiTestUtils() { + } - static GoogleJsonResponseException googleException(int statusCode, String reason) { + public static GoogleJsonResponseException googleException(int statusCode, String reason) { GoogleJsonError.ErrorInfo errorInfo = new GoogleJsonError.ErrorInfo(); errorInfo.setReason(reason); @@ -28,11 +29,11 @@ static GoogleJsonResponseException googleException(int statusCode, String reason return new GoogleJsonResponseException(builder, error); } - static HttpResponseException httpResponseException(int statusCode) { + public static HttpResponseException httpResponseException(int statusCode) { return httpResponseException(statusCode, null); } - static HttpResponseException httpResponseException(int statusCode, String content) { + public static HttpResponseException httpResponseException(int statusCode, String content) { return new HttpResponseException.Builder( statusCode, null, diff --git a/src/test/java/gg/agit/konect/domain/club/service/GoogleDrivePermissionHelperTest.java b/src/test/java/gg/agit/konect/domain/club/service/GoogleDrivePermissionHelperTest.java index cd5337d36..b40a3995b 100644 --- a/src/test/java/gg/agit/konect/domain/club/service/GoogleDrivePermissionHelperTest.java +++ b/src/test/java/gg/agit/konect/domain/club/service/GoogleDrivePermissionHelperTest.java @@ -67,15 +67,13 @@ void listAllPermissionsReturnsPermissionsAcrossPages() throws IOException { given(driveService.permissions()).willReturn(permissions); given(permissions.list(FILE_ID)).willReturn(firstListRequest, secondListRequest); - given(firstListRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) - .willReturn(firstListRequest); + stubListRequest(firstListRequest); given(firstListRequest.execute()).willReturn( new PermissionList() .setPermissions(List.of(firstPermission)) .setNextPageToken("next-page") ); - given(secondListRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) - .willReturn(secondListRequest); + stubListRequest(secondListRequest); given(secondListRequest.setPageToken("next-page")).willReturn(secondListRequest); given(secondListRequest.execute()).willReturn( new PermissionList().setPermissions(List.of(secondPermission)) @@ -94,12 +92,9 @@ void ensureServiceAccountPermissionReturnsCreatedWhenPermissionAppearsAfterRetry given(driveService.permissions()).willReturn(permissions); given(permissions.list(FILE_ID)).willReturn(initialListRequest, applyListRequest, recheckListRequest); - given(initialListRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) - .willReturn(initialListRequest); - given(applyListRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) - .willReturn(applyListRequest); - given(recheckListRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) - .willReturn(recheckListRequest); + stubListRequest(initialListRequest); + stubListRequest(applyListRequest); + stubListRequest(recheckListRequest); given(initialListRequest.execute()).willReturn(new PermissionList().setPermissions(List.of())); given(applyListRequest.execute()).willReturn(new PermissionList().setPermissions(List.of())); given(recheckListRequest.execute()).willReturn( @@ -108,6 +103,7 @@ void ensureServiceAccountPermissionReturnsCreatedWhenPermissionAppearsAfterRetry given(permissions.create(eq(FILE_ID), org.mockito.ArgumentMatchers.any(Permission.class))) .willReturn(createRequest); given(createRequest.setSendNotificationEmail(false)).willReturn(createRequest); + given(createRequest.setSupportsAllDrives(true)).willReturn(createRequest); given(createRequest.execute()).willThrow(new IOException("create failed after applying")); assertThat( @@ -129,12 +125,9 @@ void ensureServiceAccountPermissionReturnsUpgradedWhenPermissionImprovesAfterRet given(driveService.permissions()).willReturn(permissions); given(permissions.list(FILE_ID)).willReturn(initialListRequest, applyListRequest, recheckListRequest); - given(initialListRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) - .willReturn(initialListRequest); - given(applyListRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) - .willReturn(applyListRequest); - given(recheckListRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) - .willReturn(recheckListRequest); + stubListRequest(initialListRequest); + stubListRequest(applyListRequest); + stubListRequest(recheckListRequest); given(initialListRequest.execute()).willReturn( new PermissionList().setPermissions(List.of(serviceAccountPermission("perm-1", "reader"))) ); @@ -146,6 +139,7 @@ void ensureServiceAccountPermissionReturnsUpgradedWhenPermissionImprovesAfterRet ); given(permissions.update(eq(FILE_ID), eq("perm-1"), org.mockito.ArgumentMatchers.any(Permission.class))) .willReturn(updateRequest); + given(updateRequest.setSupportsAllDrives(true)).willReturn(updateRequest); given(updateRequest.execute()).willThrow(new IOException("update failed after applying")); assertThat( @@ -179,4 +173,11 @@ private Permission serviceAccountPermission(String permissionId, String role) { .setEmailAddress("service-account@project.iam.gserviceaccount.com") .setRole(role); } + + private void stubListRequest(Drive.Permissions.List request) throws IOException { + // Production code chains fluent setters before execute(), so Mockito defaults(null)를 끊어야 한다. + given(request.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(request); + given(request.setSupportsAllDrives(true)).willReturn(request); + } } diff --git a/src/test/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionServiceTest.java b/src/test/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionServiceTest.java deleted file mode 100644 index 0db70d9ea..000000000 --- a/src/test/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionServiceTest.java +++ /dev/null @@ -1,262 +0,0 @@ -package gg.agit.konect.domain.club.service; - -import static gg.agit.konect.domain.club.service.GoogleApiTestUtils.googleException; -import static gg.agit.konect.domain.club.service.GoogleApiTestUtils.httpResponseException; -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.Mockito.never; -import static org.mockito.Mockito.verify; - -import java.io.IOException; -import java.security.GeneralSecurityException; -import java.util.List; -import java.util.Optional; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.mockito.InjectMocks; -import org.mockito.Mock; - -import com.google.api.services.drive.Drive; -import com.google.api.services.drive.model.Permission; -import com.google.api.services.drive.model.PermissionList; -import com.google.auth.oauth2.ServiceAccountCredentials; - -import gg.agit.konect.domain.user.enums.Provider; -import gg.agit.konect.domain.user.model.UserOAuthAccount; -import gg.agit.konect.domain.user.repository.UserOAuthAccountRepository; -import gg.agit.konect.global.code.ApiResponseCode; -import gg.agit.konect.global.exception.CustomException; -import gg.agit.konect.infrastructure.googlesheets.GoogleSheetsConfig; -import gg.agit.konect.support.ServiceTestSupport; - -class GoogleSheetPermissionServiceTest extends ServiceTestSupport { - - private static final Integer REQUESTER_ID = 1; - private static final String FILE_ID = "spreadsheet-id"; - private static final String REFRESH_TOKEN = "refresh-token"; - private static final String SERVICE_ACCOUNT_EMAIL = "service-account@konect.iam.gserviceaccount.com"; - - @Mock - private ServiceAccountCredentials serviceAccountCredentials; - - @Mock - private GoogleSheetsConfig googleSheetsConfig; - - @Mock - private UserOAuthAccountRepository userOAuthAccountRepository; - - @Mock - private UserOAuthAccount userOAuthAccount; - - @Mock - private Drive userDriveService; - - @Mock - private Drive.Permissions permissions; - - @Mock - private Drive.Permissions.List listRequest; - - @Mock - private Drive.Permissions.List nextPageListRequest; - - @Mock - private Drive.Permissions.Create createRequest; - - @Mock - private Drive.Permissions.Update updateRequest; - - @InjectMocks - private GoogleSheetPermissionService googleSheetPermissionService; - - @Test - @DisplayName("returns false when the requester has no Google Drive OAuth account") - void tryGrantServiceAccountWriterAccessReturnsFalseWhenOAuthAccountIsMissing() { - given(userOAuthAccountRepository.findByUserIdAndProvider(REQUESTER_ID, Provider.GOOGLE)) - .willReturn(Optional.empty()); - - boolean granted = googleSheetPermissionService.tryGrantServiceAccountWriterAccess( - REQUESTER_ID, - FILE_ID - ); - - assertThat(granted).isFalse(); - } - - @Test - @DisplayName("returns true without creating when the service account already has writer access") - void tryGrantServiceAccountWriterAccessReturnsTrueWhenPermissionAlreadyExists() - throws IOException, GeneralSecurityException { - mockConnectedDriveAccount(); - given(permissions.list(FILE_ID)).willReturn(listRequest); - given(listRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) - .willReturn(listRequest); - given(listRequest.execute()).willReturn(permissionList(permission("perm-1", "writer"))); - - boolean granted = googleSheetPermissionService.tryGrantServiceAccountWriterAccess( - REQUESTER_ID, - FILE_ID - ); - - assertThat(granted).isTrue(); - verify(permissions, never()).create(eq(FILE_ID), any(Permission.class)); - verify(permissions, never()).update(eq(FILE_ID), eq("perm-1"), any(Permission.class)); - } - - @Test - @DisplayName("finds existing permission across paged Drive permission results") - void tryGrantServiceAccountWriterAccessFindsPermissionAcrossPages() - throws IOException, GeneralSecurityException { - mockConnectedDriveAccount(); - given(permissions.list(FILE_ID)).willReturn(listRequest, nextPageListRequest); - given(listRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) - .willReturn(listRequest); - given(listRequest.execute()).willReturn( - new PermissionList().setPermissions(List.of()).setNextPageToken("next-page") - ); - given(nextPageListRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) - .willReturn(nextPageListRequest); - given(nextPageListRequest.setPageToken("next-page")).willReturn(nextPageListRequest); - given(nextPageListRequest.execute()).willReturn(permissionList(permission("perm-1", "writer"))); - - boolean granted = googleSheetPermissionService.tryGrantServiceAccountWriterAccess( - REQUESTER_ID, - FILE_ID - ); - - assertThat(granted).isTrue(); - verify(permissions, never()).create(eq(FILE_ID), any(Permission.class)); - } - - @Test - @DisplayName("returns true when create fails but the permission is visible on re-check") - void tryGrantServiceAccountWriterAccessReturnsTrueAfterConcurrentGrant() - throws IOException, GeneralSecurityException { - mockConnectedDriveAccount(); - given(permissions.list(FILE_ID)).willReturn(listRequest); - given(listRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) - .willReturn(listRequest); - given(listRequest.execute()).willReturn( - permissionList(), - permissionList(permission("perm-1", "writer")) - ); - given(permissions.create(eq(FILE_ID), any(Permission.class))).willReturn(createRequest); - given(createRequest.setSendNotificationEmail(false)).willReturn(createRequest); - given(createRequest.execute()).willThrow(new IOException("already granted")); - - boolean granted = googleSheetPermissionService.tryGrantServiceAccountWriterAccess( - REQUESTER_ID, - FILE_ID - ); - - assertThat(granted).isTrue(); - verify(permissions).create(eq(FILE_ID), any(Permission.class)); - } - - @Test - @DisplayName("returns true when an existing permission needs to be upgraded to writer") - void tryGrantServiceAccountWriterAccessUpgradesExistingPermission() - throws IOException, GeneralSecurityException { - mockConnectedDriveAccount(); - given(permissions.list(FILE_ID)).willReturn(listRequest); - given(listRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) - .willReturn(listRequest); - given(listRequest.execute()).willReturn(permissionList(permission("perm-x", "reader"))); - given(permissions.update(eq(FILE_ID), eq("perm-x"), any(Permission.class))).willReturn(updateRequest); - given(updateRequest.execute()).willReturn(permission("perm-x", "writer")); - - boolean granted = googleSheetPermissionService.tryGrantServiceAccountWriterAccess( - REQUESTER_ID, - FILE_ID - ); - - assertThat(granted).isTrue(); - verify(permissions).update(eq(FILE_ID), eq("perm-x"), any(Permission.class)); - } - - @Test - @DisplayName("returns false when Google Drive auth fails during permission lookup") - void tryGrantServiceAccountWriterAccessReturnsFalseWhenAuthFails() - throws IOException, GeneralSecurityException { - mockConnectedDriveAccount(); - given(permissions.list(FILE_ID)).willReturn(listRequest); - given(listRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) - .willReturn(listRequest); - given(listRequest.execute()).willThrow(googleException(401, "authError")); - - boolean granted = googleSheetPermissionService.tryGrantServiceAccountWriterAccess( - REQUESTER_ID, - FILE_ID - ); - - assertThat(granted).isFalse(); - } - - @Test - @DisplayName("returns false when Google Drive reports access denied while listing permissions") - void tryGrantServiceAccountWriterAccessReturnsFalseWhenAccessIsDenied() - throws IOException, GeneralSecurityException { - mockConnectedDriveAccount(); - given(permissions.list(FILE_ID)).willReturn(listRequest); - given(listRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) - .willReturn(listRequest); - given(listRequest.execute()).willThrow(googleException(403, "forbidden")); - - boolean granted = googleSheetPermissionService.tryGrantServiceAccountWriterAccess( - REQUESTER_ID, - FILE_ID - ); - - assertThat(granted).isFalse(); - } - - @Test - @DisplayName("throws a bad request custom exception when Google returns invalid_grant") - void tryGrantServiceAccountWriterAccessThrowsWhenInvalidGrantOccurs() - throws IOException, GeneralSecurityException { - mockConnectedDriveAccount(); - given(permissions.list(FILE_ID)).willReturn(listRequest); - given(listRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) - .willReturn(listRequest); - given(listRequest.execute()).willThrow(new IOException( - "token refresh failed", - httpResponseException( - 400, - "{\"error\":\"invalid_grant\",\"error_description\":\"Bad Request\"}" - ) - )); - - assertThatThrownBy(() -> googleSheetPermissionService.tryGrantServiceAccountWriterAccess( - REQUESTER_ID, - FILE_ID - )) - .isInstanceOf(CustomException.class) - .extracting("errorCode") - .isEqualTo(ApiResponseCode.INVALID_GOOGLE_DRIVE_AUTH); - } - - private void mockConnectedDriveAccount() throws IOException, GeneralSecurityException { - given(userOAuthAccountRepository.findByUserIdAndProvider(REQUESTER_ID, Provider.GOOGLE)) - .willReturn(Optional.of(userOAuthAccount)); - given(userOAuthAccount.getGoogleDriveRefreshToken()).willReturn(REFRESH_TOKEN); - given(googleSheetsConfig.buildUserDriveService(REFRESH_TOKEN)).willReturn(userDriveService); - given(serviceAccountCredentials.getClientEmail()).willReturn(SERVICE_ACCOUNT_EMAIL); - given(userDriveService.permissions()).willReturn(permissions); - } - - private Permission permission(String id, String role) { - return new Permission() - .setId(id) - .setType("user") - .setEmailAddress(SERVICE_ACCOUNT_EMAIL) - .setRole(role); - } - - private PermissionList permissionList(Permission... permissions) { - return new PermissionList().setPermissions(List.of(permissions)); - } -} diff --git a/src/test/java/gg/agit/konect/integration/KonectApplicationTests.java b/src/test/java/gg/agit/konect/integration/KonectApplicationTests.java index ac1367eec..a9d5d5380 100644 --- a/src/test/java/gg/agit/konect/integration/KonectApplicationTests.java +++ b/src/test/java/gg/agit/konect/integration/KonectApplicationTests.java @@ -10,6 +10,7 @@ import com.google.api.services.drive.Drive; import com.google.api.services.sheets.v4.Sheets; import com.google.auth.oauth2.GoogleCredentials; +import com.google.auth.oauth2.ServiceAccountCredentials; import gg.agit.konect.support.TestClaudeConfig; import gg.agit.konect.support.TestMcpConfig; @@ -24,6 +25,9 @@ class KonectApplicationTests { @MockitoBean private GoogleCredentials googleCredentials; + @MockitoBean + private ServiceAccountCredentials serviceAccountCredentials; + @MockitoBean private Sheets googleSheetsService; diff --git a/src/test/java/gg/agit/konect/integration/admin/advertisement/AdminAdvertisementApiTest.java b/src/test/java/gg/agit/konect/integration/admin/advertisement/AdminAdvertisementApiTest.java new file mode 100644 index 000000000..b31e0e4eb --- /dev/null +++ b/src/test/java/gg/agit/konect/integration/admin/advertisement/AdminAdvertisementApiTest.java @@ -0,0 +1,302 @@ +package gg.agit.konect.integration.admin.advertisement; + +import static org.hamcrest.Matchers.hasSize; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +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 gg.agit.konect.admin.advertisement.dto.AdminAdvertisementCreateRequest; +import gg.agit.konect.admin.advertisement.dto.AdminAdvertisementUpdateRequest; +import gg.agit.konect.domain.advertisement.model.Advertisement; +import gg.agit.konect.domain.university.model.University; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.support.IntegrationTestSupport; +import gg.agit.konect.support.fixture.AdvertisementFixture; +import gg.agit.konect.support.fixture.UniversityFixture; +import gg.agit.konect.support.fixture.UserFixture; + +class AdminAdvertisementApiTest extends IntegrationTestSupport { + + private static final String ADMIN_ADVERTISEMENTS_ENDPOINT = "/admin/advertisements"; + + private User adminUser; + private User normalUser; + + @BeforeEach + void setUp() throws Exception { + University university = persist(UniversityFixture.create()); + adminUser = persist(UserFixture.createAdmin(university)); + normalUser = persist(UserFixture.createUser(university, "일반유저", "2021136001")); + clearPersistenceContext(); + } + + @Nested + @DisplayName("GET /admin/advertisements - 광고 목록 조회") + class GetAdvertisements { + + @Test + @DisplayName("광고 목록을 최신순으로 조회한다") + void getAdvertisementsSuccess() throws Exception { + // given + mockLoginUser(adminUser.getId()); + Advertisement first = persist(AdvertisementFixture.create("첫 번째 광고", true)); + Advertisement second = persist(AdvertisementFixture.create("두 번째 광고", false)); + clearPersistenceContext(); + + // when & then + performGet(ADMIN_ADVERTISEMENTS_ENDPOINT) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.advertisements", hasSize(2))) + .andExpect(jsonPath("$.advertisements[0].id").value(second.getId())) + .andExpect(jsonPath("$.advertisements[1].id").value(first.getId())); + } + + @Test + @DisplayName("광고가 없으면 빈 목록을 반환한다") + void getAdvertisementsWhenEmpty() throws Exception { + // given + mockLoginUser(adminUser.getId()); + + // when & then + performGet(ADMIN_ADVERTISEMENTS_ENDPOINT) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.advertisements", hasSize(0))); + } + } + + @Nested + @DisplayName("GET /admin/advertisements/{id} - 광고 단건 조회") + class GetAdvertisement { + + @Test + @DisplayName("광고 단건을 조회한다") + void getAdvertisementSuccess() throws Exception { + // given + mockLoginUser(adminUser.getId()); + Advertisement advertisement = persist(AdvertisementFixture.create("광고 제목", true)); + clearPersistenceContext(); + + // when & then + performGet(ADMIN_ADVERTISEMENTS_ENDPOINT + "/" + advertisement.getId()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(advertisement.getId())) + .andExpect(jsonPath("$.title").value("광고 제목")) + .andExpect(jsonPath("$.isVisible").value(true)); + } + + @Test + @DisplayName("존재하지 않는 광고면 404를 반환한다") + void getAdvertisementNotFound() throws Exception { + // given + mockLoginUser(adminUser.getId()); + + // when & then + performGet(ADMIN_ADVERTISEMENTS_ENDPOINT + "/99999") + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.NOT_FOUND_ADVERTISEMENT.getCode())); + } + } + + @Nested + @DisplayName("POST /admin/advertisements - 광고 생성") + class CreateAdvertisement { + + @Test + @DisplayName("광고를 생성한다") + void createAdvertisementSuccess() throws Exception { + // given + mockLoginUser(adminUser.getId()); + + // when & then + performPost(ADMIN_ADVERTISEMENTS_ENDPOINT, createRequest("생성 광고", true)) + .andExpect(status().isOk()); + + performGet(ADMIN_ADVERTISEMENTS_ENDPOINT) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.advertisements", hasSize(1))) + .andExpect(jsonPath("$.advertisements[0].title").value("생성 광고")) + .andExpect(jsonPath("$.advertisements[0].isVisible").value(true)) + .andExpect(jsonPath("$.advertisements[0].clickCount").value(0)); + } + + @Test + @DisplayName("광고 제목이 비어 있으면 400을 반환한다") + void createAdvertisementInvalidBodyFails() throws Exception { + // given + mockLoginUser(adminUser.getId()); + AdminAdvertisementCreateRequest request = new AdminAdvertisementCreateRequest( + " ", + "광고 설명", + "https://example.com/image.png", + "https://example.com/link", + true + ); + + // when & then + performPost(ADMIN_ADVERTISEMENTS_ENDPOINT, request) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.INVALID_REQUEST_BODY.getCode())); + } + + @Test + @DisplayName("어드민 권한이 없으면 403을 반환한다") + void createAdvertisementForbidden() throws Exception { + // given + mockLoginUser(normalUser.getId()); + given(authorizationInterceptor.preHandle(any(), any(), any())) + .willThrow(CustomException.of(ApiResponseCode.FORBIDDEN_ROLE_ACCESS)); + + // when & then + performPost(ADMIN_ADVERTISEMENTS_ENDPOINT, createRequest("권한 없음", true)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.FORBIDDEN_ROLE_ACCESS.getCode())); + } + } + + @Nested + @DisplayName("PUT /admin/advertisements/{id} - 광고 수정") + class UpdateAdvertisement { + + @Test + @DisplayName("광고를 수정한다") + void updateAdvertisementSuccess() throws Exception { + // given + mockLoginUser(adminUser.getId()); + Advertisement advertisement = persist(AdvertisementFixture.create("기존 광고", true)); + clearPersistenceContext(); + + AdminAdvertisementUpdateRequest request = new AdminAdvertisementUpdateRequest( + "수정 광고", + "수정 설명", + "https://example.com/new-image.png", + "https://example.com/new-link", + false + ); + + // when & then + performPut(ADMIN_ADVERTISEMENTS_ENDPOINT + "/" + advertisement.getId(), request) + .andExpect(status().isOk()); + + performGet(ADMIN_ADVERTISEMENTS_ENDPOINT + "/" + advertisement.getId()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.title").value("수정 광고")) + .andExpect(jsonPath("$.description").value("수정 설명")) + .andExpect(jsonPath("$.isVisible").value(false)); + } + + @Test + @DisplayName("수정 대상 광고가 없으면 404를 반환한다") + void updateAdvertisementNotFound() throws Exception { + // given + mockLoginUser(adminUser.getId()); + AdminAdvertisementUpdateRequest request = new AdminAdvertisementUpdateRequest( + "수정 광고", + "수정 설명", + "https://example.com/new-image.png", + "https://example.com/new-link", + false + ); + + // when & then + performPut(ADMIN_ADVERTISEMENTS_ENDPOINT + "/99999", request) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.NOT_FOUND_ADVERTISEMENT.getCode())); + } + + @Test + @DisplayName("광고 수정은 기존 클릭 수를 보존한다") + void updateAdvertisementPreservesClickCount() throws Exception { + // given + mockLoginUser(adminUser.getId()); + Integer advertisementId = insertAdvertisement("기존 광고", true, 7); + clearPersistenceContext(); + + AdminAdvertisementUpdateRequest request = new AdminAdvertisementUpdateRequest( + "수정 광고", + "수정 설명", + "https://example.com/new-image.png", + "https://example.com/new-link", + false + ); + + // when & then + performPut(ADMIN_ADVERTISEMENTS_ENDPOINT + "/" + advertisementId, request) + .andExpect(status().isOk()); + + performGet(ADMIN_ADVERTISEMENTS_ENDPOINT + "/" + advertisementId) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.clickCount").value(7)); + } + } + + @Nested + @DisplayName("DELETE /admin/advertisements/{id} - 광고 삭제") + class DeleteAdvertisement { + + @Test + @DisplayName("광고를 삭제한다") + void deleteAdvertisementSuccess() throws Exception { + // given + mockLoginUser(adminUser.getId()); + Advertisement advertisement = persist(AdvertisementFixture.create("삭제 광고", true)); + clearPersistenceContext(); + + // when & then + performDelete(ADMIN_ADVERTISEMENTS_ENDPOINT + "/" + advertisement.getId()) + .andExpect(status().isOk()); + + performGet(ADMIN_ADVERTISEMENTS_ENDPOINT + "/" + advertisement.getId()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.NOT_FOUND_ADVERTISEMENT.getCode())); + } + + @Test + @DisplayName("삭제 대상 광고가 없으면 404를 반환한다") + void deleteAdvertisementNotFound() throws Exception { + // given + mockLoginUser(adminUser.getId()); + + // when & then + performDelete(ADMIN_ADVERTISEMENTS_ENDPOINT + "/99999") + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.NOT_FOUND_ADVERTISEMENT.getCode())); + } + } + + private AdminAdvertisementCreateRequest createRequest(String title, boolean isVisible) { + return new AdminAdvertisementCreateRequest( + title, + title + " 설명", + "https://example.com/image.png", + "https://example.com/link", + isVisible + ); + } + + private Integer insertAdvertisement(String title, boolean isVisible, int clickCount) { + entityManager.createNativeQuery(""" + insert into advertisement ( + title, description, image_url, link_url, is_visible, click_count, created_at, updated_at + ) values (?, ?, ?, ?, ?, ?, current_timestamp, current_timestamp) + """) + .setParameter(1, title) + .setParameter(2, title + " 설명") + .setParameter(3, "https://example.com/image.png") + .setParameter(4, "https://example.com/link") + .setParameter(5, isVisible) + .setParameter(6, clickCount) + .executeUpdate(); + + entityManager.flush(); + return ((Number)entityManager.createNativeQuery("select max(id) from advertisement") + .getSingleResult()).intValue(); + } +} diff --git a/src/test/java/gg/agit/konect/integration/admin/schedule/AdminScheduleApiTest.java b/src/test/java/gg/agit/konect/integration/admin/schedule/AdminScheduleApiTest.java index 6ba3b40a0..d73de5c93 100644 --- a/src/test/java/gg/agit/konect/integration/admin/schedule/AdminScheduleApiTest.java +++ b/src/test/java/gg/agit/konect/integration/admin/schedule/AdminScheduleApiTest.java @@ -14,6 +14,10 @@ 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.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; import gg.agit.konect.admin.schedule.dto.AdminScheduleCreateRequest; import gg.agit.konect.admin.schedule.dto.AdminScheduleUpsertItemRequest; @@ -34,6 +38,9 @@ class AdminScheduleApiTest extends IntegrationTestSupport { private static final String BASE_URL = "/admin/schedules"; + @Autowired + private PlatformTransactionManager transactionManager; + private University university; private User admin; @@ -515,11 +522,11 @@ void upsertSchedulesFailWithOneInvalidItem() throws Exception { clearPersistenceContext(); - List saved = entityManager.createQuery( + List saved = newTransaction().execute(status -> entityManager.createQuery( "SELECT us FROM UniversitySchedule us WHERE us.university.id = :universityId", UniversitySchedule.class) .setParameter("universityId", university.getId()) - .getResultList(); + .getResultList()); assertThat(saved).isEmpty(); } @@ -754,4 +761,11 @@ void nonAdminCannotUpsertSchedules() throws Exception { .andExpect(jsonPath("$.code").value(ApiResponseCode.FORBIDDEN_ROLE_ACCESS.getCode())); } } + + private TransactionTemplate newTransaction() { + TransactionTemplate template = new TransactionTemplate(transactionManager); + template.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + template.setReadOnly(true); + return template; + } } diff --git a/src/test/java/gg/agit/konect/integration/admin/version/AdminVersionApiTest.java b/src/test/java/gg/agit/konect/integration/admin/version/AdminVersionApiTest.java new file mode 100644 index 000000000..40d78df6c --- /dev/null +++ b/src/test/java/gg/agit/konect/integration/admin/version/AdminVersionApiTest.java @@ -0,0 +1,137 @@ +package gg.agit.konect.integration.admin.version; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +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 gg.agit.konect.admin.version.dto.AdminVersionCreateRequest; +import gg.agit.konect.domain.university.model.University; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.version.enums.PlatformType; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.support.IntegrationTestSupport; +import gg.agit.konect.support.fixture.UniversityFixture; +import gg.agit.konect.support.fixture.UserFixture; + +class AdminVersionApiTest extends IntegrationTestSupport { + + private static final String ADMIN_VERSIONS_ENDPOINT = "/admin/versions"; + private static final String LATEST_VERSION_ENDPOINT = "/versions/latest"; + + private User adminUser; + private User normalUser; + + @BeforeEach + void setUp() { + University university = persist(UniversityFixture.create()); + adminUser = persist(UserFixture.createAdmin(university)); + normalUser = persist(UserFixture.createUser(university, "일반유저", "2021136001")); + } + + @Nested + @DisplayName("POST /admin/versions - 버전 등록") + class CreateVersion { + + @Test + @DisplayName("관리자가 새 버전을 등록한다") + void createVersionSuccess() throws Exception { + // given + mockLoginUser(adminUser.getId()); + AdminVersionCreateRequest request = new AdminVersionCreateRequest( + PlatformType.IOS, "1.2.3", "새 기능 추가" + ); + + // when & then + performPost(ADMIN_VERSIONS_ENDPOINT, request) + .andExpect(status().isOk()); + + performGet(LATEST_VERSION_ENDPOINT + "?platform=IOS") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.version").value("1.2.3")) + .andExpect(jsonPath("$.releaseNotes").value("새 기능 추가")); + } + + @Test + @DisplayName("같은 플랫폼/버전을 중복 등록하면 409를 반환한다") + void createVersionDuplicateFails() throws Exception { + // given + mockLoginUser(adminUser.getId()); + AdminVersionCreateRequest request = createRequest(PlatformType.ANDROID, "2.0.0"); + + performPost(ADMIN_VERSIONS_ENDPOINT, request) + .andExpect(status().isOk()); + + // when & then + performPost(ADMIN_VERSIONS_ENDPOINT, request) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.DUPLICATE_VERSION.getCode())); + } + + @Test + @DisplayName("버전 값이 비어 있으면 400을 반환한다") + void createVersionWithBlankVersionFails() throws Exception { + // given + mockLoginUser(adminUser.getId()); + AdminVersionCreateRequest request = createRequest(PlatformType.IOS, " "); + + // when & then + performPost(ADMIN_VERSIONS_ENDPOINT, request) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.INVALID_REQUEST_BODY.getCode())); + } + + @Test + @DisplayName("관리자 권한이 없으면 403을 반환한다") + void createVersionWithoutAdminRoleFails() throws Exception { + // given + mockLoginUser(normalUser.getId()); + given(authorizationInterceptor.preHandle(any(), any(), any())) + .willThrow(CustomException.of(ApiResponseCode.FORBIDDEN_ROLE_ACCESS)); + + AdminVersionCreateRequest request = createRequest(PlatformType.IOS, "1.0.0"); + + // when & then + performPost(ADMIN_VERSIONS_ENDPOINT, request) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.FORBIDDEN_ROLE_ACCESS.getCode())); + } + + @Test + @DisplayName("같은 버전 문자열이라도 플랫폼이 다르면 중복이 아니다") + void createVersionWithSameVersionDifferentPlatformSucceeds() throws Exception { + // given + mockLoginUser(adminUser.getId()); + + // when & then + performPost(ADMIN_VERSIONS_ENDPOINT, createRequest(PlatformType.IOS, "3.0.0")) + .andExpect(status().isOk()); + + performPost(ADMIN_VERSIONS_ENDPOINT, createRequest(PlatformType.ANDROID, "3.0.0")) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("플랫폼이 없으면 400을 반환한다") + void createVersionWithoutPlatformFails() throws Exception { + // given + mockLoginUser(adminUser.getId()); + AdminVersionCreateRequest request = new AdminVersionCreateRequest(null, "1.0.0", "릴리즈 노트"); + + // when & then + performPost(ADMIN_VERSIONS_ENDPOINT, request) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.INVALID_REQUEST_BODY.getCode())); + } + } + + private AdminVersionCreateRequest createRequest(PlatformType platform, String version) { + return new AdminVersionCreateRequest(platform, version, "릴리즈 노트"); + } +} diff --git a/src/test/java/gg/agit/konect/integration/domain/bank/BankApiTest.java b/src/test/java/gg/agit/konect/integration/domain/bank/BankApiTest.java new file mode 100644 index 000000000..820f82f42 --- /dev/null +++ b/src/test/java/gg/agit/konect/integration/domain/bank/BankApiTest.java @@ -0,0 +1,56 @@ +package gg.agit.konect.integration.domain.bank; + +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import gg.agit.konect.support.IntegrationTestSupport; + +class BankApiTest extends IntegrationTestSupport { + + private static final String BANKS_ENDPOINT = "/banks"; + + @Nested + @DisplayName("GET /banks - 은행 목록 조회") + class GetBanks { + + @Test + @DisplayName("등록된 은행 목록을 조회한다") + void getBanksSuccess() throws Exception { + // given + insertBank("국민은행", "https://example.com/kb.png"); + insertBank("카카오뱅크", "https://example.com/kakao.png"); + clearPersistenceContext(); + + // when & then + performGet(BANKS_ENDPOINT) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.banks", hasSize(2))) + .andExpect(jsonPath("$.banks[0].name").value("국민은행")) + .andExpect(jsonPath("$.banks[1].name").value("카카오뱅크")); + } + + @Test + @DisplayName("등록된 은행이 없으면 빈 목록을 반환한다") + void getBanksWhenEmpty() throws Exception { + // when & then + performGet(BANKS_ENDPOINT) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.banks", hasSize(0))); + } + } + + private void insertBank(String name, String imageUrl) { + entityManager.createNativeQuery(""" + insert into bank (name, image_url, created_at, updated_at) + values (?, ?, current_timestamp, current_timestamp) + """) + .setParameter(1, name) + .setParameter(2, imageUrl) + .executeUpdate(); + } +} diff --git a/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java b/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java index 6b988082b..b7a479826 100644 --- a/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java @@ -1,6 +1,8 @@ package gg.agit.konect.integration.domain.chat; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.verify; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -14,7 +16,15 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.PageRequest; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.jdbc.SqlConfig; import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.transaction.TestTransaction; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.util.LinkedMultiValueMap; import gg.agit.konect.domain.chat.dto.ChatMessageSendRequest; import gg.agit.konect.domain.chat.dto.ChatRoomCreateRequest; @@ -40,10 +50,20 @@ import gg.agit.konect.support.fixture.UniversityFixture; import gg.agit.konect.support.fixture.UserFixture; -import org.springframework.util.LinkedMultiValueMap; - class ChatApiTest extends IntegrationTestSupport { + private static final int SYSTEM_ADMIN_ID = 1; + private static final String CHAT_TEST_DATA_CLEANUP_SQL = """ + DELETE FROM notification_mute_setting; + DELETE FROM chat_message; + DELETE FROM chat_room_member; + DELETE FROM chat_room; + DELETE FROM club_member; + DELETE FROM club; + DELETE FROM users; + DELETE FROM university; + """; + @Autowired private ChatRoomRepository chatRoomRepository; @@ -56,6 +76,9 @@ class ChatApiTest extends IntegrationTestSupport { @Autowired private NotificationMuteSettingRepository notificationMuteSettingRepository; + @Autowired + private TransactionTemplate transactionTemplate; + @MockitoBean private ChatPresenceService chatPresenceService; @@ -69,10 +92,131 @@ class ChatApiTest extends IntegrationTestSupport { private University university; @BeforeEach - void setUp() { - university = persist(UniversityFixture.create()); - normalUser = persist(UserFixture.createUser(university, "일반유저", "2021136001")); + public void setUp() { + transactionTemplate.execute(status -> { + university = persist(UniversityFixture.create()); + // System Admin을 먼저 생성 - 문의 채팅방용 + adminUser = persist(UserFixture.createAdmin(university)); + // 일부 테스트는 NOT_SUPPORTED로 실행되므로, 공통 픽스처는 명시적 트랜잭션 안에서 만든다. + if (adminUser.getId() != SYSTEM_ADMIN_ID) { + entityManager.createNativeQuery(""" + INSERT INTO users (id, email, name, student_number, role, is_marketing_agreement, image_url, university_id, created_at, updated_at) + SELECT ?, 'system@koreatech.ac.kr', '시스템관리자', '2021000001', 'ADMIN', true, 'https://example.com/system-admin.png', ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + WHERE NOT EXISTS (SELECT 1 FROM users WHERE id = ?) + """ + ).setParameter(1, SYSTEM_ADMIN_ID) + .setParameter(2, university.getId()) + .setParameter(3, SYSTEM_ADMIN_ID) + .executeUpdate(); + entityManager.flush(); + } + normalUser = persist(UserFixture.createUser(university, "일반유저", "2021136001")); + clearPersistenceContext(); + return null; + }); + } + + @Transactional + public ChatRoom createDirectChatRoom(User firstUser, User secondUser) { + ChatRoom chatRoom = persist(ChatRoom.directOf()); + LocalDateTime joinedAt = chatRoom.getCreatedAt(); + ChatRoom managedChatRoom = entityManager.getReference(ChatRoom.class, chatRoom.getId()); + User managedFirstUser = entityManager.getReference(User.class, firstUser.getId()); + User managedSecondUser = entityManager.getReference(User.class, secondUser.getId()); + + persist(ChatRoomMember.of(managedChatRoom, managedFirstUser, joinedAt)); + persist(ChatRoomMember.of(managedChatRoom, managedSecondUser, joinedAt)); + clearPersistenceContext(); + return chatRoom; + } + + private ChatRoom createGroupChatRoomWithOwner(User owner, User... members) { + ChatRoom groupRoom = persist(ChatRoom.groupOf()); + ChatRoom managedRoom = entityManager.getReference(ChatRoom.class, groupRoom.getId()); + User managedOwner = entityManager.getReference(User.class, owner.getId()); + persist(ChatRoomMember.ofOwner(managedRoom, managedOwner, groupRoom.getCreatedAt())); + for (User member : members) { + User managedMember = entityManager.getReference(User.class, member.getId()); + persist(ChatRoomMember.of(managedRoom, managedMember, groupRoom.getCreatedAt())); + } + clearPersistenceContext(); + return groupRoom; + } + + private User createUser(String name, String studentId) { + return persist(UserFixture.createUser(university, name, studentId)); + } + + private ClubMember createClubMember(Club club, User user) { + Club managedClub = entityManager.getReference(Club.class, club.getId()); + User managedUser = entityManager.getReference(User.class, user.getId()); + ClubMember clubMember = persist(ClubMember.builder() + .club(managedClub) + .user(managedUser) + .clubPosition(ClubPosition.MEMBER) + .build()); + clearPersistenceContext(); + return clubMember; + } + + private ChatMessage persistChatMessage(ChatRoom chatRoom, User sender, String content) { + ChatRoom managedChatRoom = entityManager.getReference(ChatRoom.class, chatRoom.getId()); + User managedSender = entityManager.getReference(User.class, sender.getId()); + + ChatMessage chatMessage = persist(ChatMessage.of(managedChatRoom, managedSender, content)); + managedChatRoom.updateLastMessage(chatMessage.getContent(), chatMessage.getCreatedAt()); + entityManager.flush(); clearPersistenceContext(); + return chatMessage; + } + + private void addRoomMember(ChatRoom chatRoom, User user) { + ChatRoom managedChatRoom = entityManager.getReference(ChatRoom.class, chatRoom.getId()); + User managedUser = entityManager.getReference(User.class, user.getId()); + persist(ChatRoomMember.of(managedChatRoom, managedUser, chatRoom.getCreatedAt())); + } + + private void createGroupedInviteCandidates(String clubName, String namePrefix, int count) { + Club club = persist(ClubFixture.create(university, clubName)); + Club managedClub = entityManager.getReference(Club.class, club.getId()); + User managedNormalUser = entityManager.getReference(User.class, normalUser.getId()); + + persist(ClubMember.builder() + .club(managedClub) + .user(managedNormalUser) + .clubPosition(ClubPosition.MEMBER) + .build()); + + ChatRoom groupRoom = persist(ChatRoom.clubGroupOf(club)); + addRoomMember(groupRoom, normalUser); + + for (int index = 1; index <= count; index++) { + User candidate = createUser( + String.format("%s%02d", namePrefix, index), + String.format("202199%04d", index + count * 10) + ); + User managedCandidate = entityManager.getReference(User.class, candidate.getId()); + persist(ClubMember.builder() + .club(managedClub) + .user(managedCandidate) + .clubPosition(ClubPosition.MEMBER) + .build()); + addRoomMember(groupRoom, candidate); + } + } + + private long countDirectRoomsBetween(User firstUser, User secondUser) { + return chatRoomRepository.findByUserId(firstUser.getId(), ChatType.DIRECT).stream() + .map(ChatRoom::getId) + .filter(roomId -> isDirectRoomBetween(roomId, firstUser.getId(), secondUser.getId())) + .count(); + } + + private boolean isDirectRoomBetween(Integer roomId, Integer firstUserId, Integer secondUserId) { + List roomMembers = chatRoomMemberRepository.findByChatRoomId(roomId); + return roomMembers.size() == 2 + && roomMembers.stream().anyMatch(member -> member.getUserId().equals(firstUserId)) + && roomMembers.stream().anyMatch(member -> member.getUserId().equals(secondUserId)); } @Nested @@ -157,7 +301,7 @@ class AdminChatRoom { @BeforeEach void setUpAdminChatFixture() { - adminUser = persist(UserFixture.createAdmin(university)); + // System Admin(ID=1)은 이미 setUp()에서 생성됨 clearPersistenceContext(); } @@ -172,14 +316,156 @@ void createAdminChatRoomAndGetRoomsSuccess() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.chatRoomId").isNumber()); - // then + // then - 일반 사용자 관점에서 채팅방이 목록에 보임 performGet("/chats/rooms") .andExpect(status().isOk()) .andExpect(jsonPath("$.rooms[0].chatType").value("DIRECT")) - .andExpect(jsonPath("$.rooms[0].roomName").value(adminUser.getName())) + .andExpect(jsonPath("$.rooms[0].roomName").exists()) .andExpect(jsonPath("$.rooms[0].lastMessage").doesNotExist()) .andExpect(jsonPath("$.rooms[0].isMuted").value(false)); } + + @Test + @DisplayName("관리자가 문의방을 다시 열어도 관리자 멤버는 추가되지 않는다") + void adminCreateOrGetInquiryRoomDoesNotAddAdminMember() throws Exception { + User anotherAdmin = persist(UserFixture.createAdmin(university)); + clearPersistenceContext(); + + mockLoginUser(normalUser.getId()); + var createResult = performPost("/chats/rooms/admin") + .andExpect(status().isOk()) + .andReturn(); + + int chatRoomId = parseChatRoomId(createResult); + + mockLoginUser(anotherAdmin.getId()); + performPost("/chats/rooms", new ChatRoomCreateRequest(normalUser.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.chatRoomId").value(chatRoomId)); + + clearPersistenceContext(); + + assertThat(chatRoomRepository.findByTwoUsers(SYSTEM_ADMIN_ID, normalUser.getId(), ChatType.DIRECT)) + .isPresent() + .get() + .extracting(ChatRoom::getId) + .isEqualTo(chatRoomId); + assertThat(chatRoomMemberRepository.findByChatRoomId(chatRoomId)) + .extracting(ChatRoomMember::getUserId) + .containsExactlyInAnyOrder(SYSTEM_ADMIN_ID, normalUser.getId()); + } + + @Test + @DisplayName("관리자는 멤버가 아니어도 문의방 메시지를 조회할 수 있다") + void adminCanReadInquiryRoomMessagesWithoutMembership() throws Exception { + User anotherAdmin = persist(UserFixture.createAdmin(university)); + clearPersistenceContext(); + + mockLoginUser(normalUser.getId()); + int chatRoomId = parseChatRoomId( + performPost("/chats/rooms/admin") + .andExpect(status().isOk()) + .andReturn() + ); + + performPost("/chats/rooms/" + chatRoomId + "/messages", + new ChatMessageSendRequest("문의 내용입니다")) + .andExpect(status().isOk()); + + clearPersistenceContext(); + + mockLoginUser(anotherAdmin.getId()); + performGet("/chats/rooms/" + chatRoomId + "?page=1&limit=20") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalCount").value(1)) + .andExpect(jsonPath("$.messages[0].content").value("문의 내용입니다")) + .andExpect(jsonPath("$.messages[0].isMine").value(false)); + + assertThat(chatRoomMemberRepository.findByChatRoomId(chatRoomId)) + .extracting(ChatRoomMember::getUserId) + .containsExactlyInAnyOrder(SYSTEM_ADMIN_ID, normalUser.getId()); + } + + @Test + @DisplayName("어드민이 나간 문의 채팅방에 사용자가 새 메시지를 보내 어드민 목록에 다시 노출된다") + @Transactional(propagation = Propagation.REQUIRES_NEW) + @Sql( + statements = CHAT_TEST_DATA_CLEANUP_SQL, + config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED), + executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD + ) + void adminLeftInquiryRoomReappearsWhenUserSendsNewMessage() throws Exception { + // given - 문의 채팅방 생성 (일반 사용자 -> system admin) + mockLoginUser(normalUser.getId()); + var createResult = performPost("/chats/rooms/admin") + .andExpect(status().isOk()) + .andReturn(); + int chatRoomId = parseChatRoomId(createResult); + + // 사용자가 메시지 전송 (목록에 노출되기 위한 조건) + performPost("/chats/rooms/" + chatRoomId + "/messages", + new ChatMessageSendRequest("첫 문의 메시지입니다")) + .andExpect(status().isOk()); + + // system admin(ID=1)이 목록에서 방을 확인 + mockLoginUser(SYSTEM_ADMIN_ID); + var adminRoomsBefore = performGet("/chats/rooms") + .andExpect(status().isOk()) + .andReturn(); + assertThat(extractRoomIds(adminRoomsBefore)).contains(chatRoomId); + + // system admin(ID=1)이 문의 채팅방 나가기 + performDelete("/chats/rooms/" + chatRoomId) + .andExpect(status().isNoContent()); + + // when - system admin이 목록 조회하면 나간 방은 안 보임 + var adminRoomsAfterLeave = performGet("/chats/rooms") + .andExpect(status().isOk()) + .andReturn(); + assertThat(extractRoomIds(adminRoomsAfterLeave)).doesNotContain(chatRoomId); + + // 사용자가 다시 메시지 전송 + mockLoginUser(normalUser.getId()); + performPost("/chats/rooms/" + chatRoomId + "/messages", + new ChatMessageSendRequest("추가 문의 메시지입니다")) + .andExpect(status().isOk()); + + // lastMessageSentAt 강제 업데이트 (테스트 트랜잭션 롤백으로 인한 workaround) + entityManager.createNativeQuery( + "UPDATE chat_room SET last_message_sent_at = CURRENT_TIMESTAMP WHERE id = ?" + ).setParameter(1, chatRoomId).executeUpdate(); + entityManager.flush(); + + // then - system admin이 목록 조회하면 다시 보임 + mockLoginUser(SYSTEM_ADMIN_ID); + var adminRoomsAfterNewMessage = performGet("/chats/rooms") + .andExpect(status().isOk()) + .andReturn(); + assertThat(extractRoomIds(adminRoomsAfterNewMessage)).contains(chatRoomId); + } + + private int parseChatRoomId(org.springframework.test.web.servlet.MvcResult result) throws Exception { + String responseBody = result.getResponse().getContentAsString(); + return objectMapper.readTree(responseBody).get("chatRoomId").asInt(); + } + + private List extractRoomIds(org.springframework.test.web.servlet.MvcResult result) throws Exception { + String responseBody = result.getResponse().getContentAsString(); + com.fasterxml.jackson.databind.JsonNode root = objectMapper.readTree(responseBody); + com.fasterxml.jackson.databind.JsonNode rooms = root.get("rooms"); + List roomIds = new java.util.ArrayList<>(); + if (rooms != null && rooms.isArray()) { + for (com.fasterxml.jackson.databind.JsonNode room : rooms) { + // roomId 또는 chatRoomId 필드 확인 + com.fasterxml.jackson.databind.JsonNode roomIdNode = + room.has("chatRoomId") ? room.get("chatRoomId") : room.get("roomId"); + if (roomIdNode != null) { + roomIds.add(roomIdNode.asInt()); + } + } + } + return roomIds; + } } @Nested @@ -432,6 +718,31 @@ void sendMessageSuccess() throws Exception { .containsExactly("안녕하세요"); } + @Test + @DisplayName("관리자가 문의방에 답변하면 실제 문의 사용자에게 알림을 보낸다") + void adminReplySendsNotificationToInquiryUser() throws Exception { + User anotherAdmin = persist(UserFixture.createAdmin(university)); + clearPersistenceContext(); + + mockLoginUser(normalUser.getId()); + int roomId = objectMapper.readTree( + performPost("/chats/rooms/admin") + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString() + ).get("chatRoomId").asInt(); + + clearInvocations(notificationService); + + mockLoginUser(anotherAdmin.getId()); + performPost("/chats/rooms/" + roomId + "/messages", new ChatMessageSendRequest("관리자 답변입니다")) + .andExpect(status().isOk()); + + verify(notificationService) + .sendChatNotification(normalUser.getId(), roomId, anotherAdmin.getName(), "관리자 답변입니다"); + } + @Test @DisplayName("빈 메시지를 전송하면 400을 반환한다") void sendBlankMessageFails() throws Exception { @@ -546,18 +857,29 @@ void updateChatRoomNameForbidden() throws Exception { @Nested @DisplayName("DELETE /chats/rooms/{chatRoomId} - 채팅방 나가기") + @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) class LeaveChatRoom { @BeforeEach void setUpLeaveFixture() { - targetUser = createUser("상대유저", "2021136002"); - clearPersistenceContext(); + transactionTemplate.execute(status -> { + targetUser = createUser("상대유저", "2021136002"); + clearPersistenceContext(); + return null; + }); } @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) @DisplayName("1:1 채팅방을 나가면 목록에서 숨겨지고 새 메시지부터 다시 보인다") void leaveDirectChatRoomAndShowOnlyNewMessages() throws Exception { - ChatRoom chatRoom = createDirectChatRoom(normalUser, targetUser); + // 트랜잭션 내에서 테스트 데이터 생성 (NOT_SUPPORTED 테스트는 트랜잭션 없이 실행됨) + ChatRoom[] chatRoomHolder = new ChatRoom[1]; + transactionTemplate.execute(status -> { + chatRoomHolder[0] = createDirectChatRoom(normalUser, targetUser); + return null; + }); + ChatRoom chatRoom = chatRoomHolder[0]; mockLoginUser(normalUser.getId()); performPost("/chats/rooms/" + chatRoom.getId() + "/messages", new ChatMessageSendRequest("첫 메시지")) @@ -566,11 +888,14 @@ void leaveDirectChatRoomAndShowOnlyNewMessages() throws Exception { performDelete("/chats/rooms/" + chatRoom.getId()) .andExpect(status().isNoContent()); - clearPersistenceContext(); - ChatRoomMember leftMember = chatRoomMemberRepository - .findByChatRoomIdAndUserId(chatRoom.getId(), normalUser.getId()) - .orElseThrow(); - assertThat(leftMember.hasLeft()).isTrue(); + transactionTemplate.execute(status -> { + clearPersistenceContext(); + ChatRoomMember leftMember = chatRoomMemberRepository + .findByChatRoomIdAndUserId(chatRoom.getId(), normalUser.getId()) + .orElseThrow(); + assertThat(leftMember.hasLeft()).isTrue(); + return null; + }); mockLoginUser(normalUser.getId()); performGet("/chats/rooms") @@ -617,6 +942,10 @@ void createOrGetChatRoomAfterLeaveStartsFresh() throws Exception { performDelete("/chats/rooms/" + chatRoom.getId()) .andExpect(status().isNoContent()); + // 트랜잭션 커밋 + TestTransaction.flagForCommit(); + TestTransaction.end(); + performPost("/chats/rooms", new ChatRoomCreateRequest(targetUser.getId())) .andExpect(status().isOk()) .andExpect(jsonPath("$.chatRoomId").value(chatRoom.getId())); @@ -720,9 +1049,18 @@ void getMessagesNotFound() throws Exception { @Test @DisplayName("참여하지 않은 사용자가 조회하면 403을 반환한다") + @Sql( + statements = CHAT_TEST_DATA_CLEANUP_SQL, + config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED), + executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD + ) void getMessagesForbidden() throws Exception { // given ChatRoom chatRoom = createDirectChatRoom(normalUser, targetUser); + + TestTransaction.flagForCommit(); + TestTransaction.end(); + mockLoginUser(outsiderUser.getId()); // when & then @@ -769,7 +1107,7 @@ void searchChatsReturnsRoomMatchesForDirectAndGroupRooms() throws Exception { .andExpect(jsonPath("$.roomMatches.rooms[0].roomName").value("개발팀")) .andExpect(jsonPath("$.roomMatches.rooms[0].chatType").value("DIRECT")) .andExpect(jsonPath("$.roomMatches.rooms[1].roomName").value("개발동아리")) - .andExpect(jsonPath("$.roomMatches.rooms[1].chatType").value("GROUP")) + .andExpect(jsonPath("$.roomMatches.rooms[1].chatType").value("CLUB_GROUP")) .andExpect(jsonPath("$.messageMatches.totalCount").value(0)) .andExpect(jsonPath("$.messageMatches.currentCount").value(0)); } @@ -956,92 +1294,755 @@ void toggleMuteSuccessAndDuplicateProcessing() throws Exception { } } - private ChatRoom createDirectChatRoom(User firstUser, User secondUser) { - ChatRoom chatRoom = persist(ChatRoom.directOf()); - LocalDateTime joinedAt = chatRoom.getCreatedAt(); - ChatRoom managedChatRoom = entityManager.getReference(ChatRoom.class, chatRoom.getId()); - User managedFirstUser = entityManager.getReference(User.class, firstUser.getId()); - User managedSecondUser = entityManager.getReference(User.class, secondUser.getId()); + @Nested + @DisplayName("POST /chats/rooms/group - 그룹 채팅방 생성") + class CreateGroupChatRoom { - persist(ChatRoomMember.of(managedChatRoom, managedFirstUser, joinedAt)); - persist(ChatRoomMember.of(managedChatRoom, managedSecondUser, joinedAt)); - clearPersistenceContext(); - return chatRoom; - } + private User memberA; + private User memberB; - private User createUser(String name, String studentId) { - return persist(UserFixture.createUser(university, name, studentId)); - } + @BeforeEach + void setUpGroupChatFixture() { + memberA = createUser("멤버A", "2021136002"); + memberB = createUser("멤버B", "2021136003"); + clearPersistenceContext(); + } - private ClubMember createClubMember(Club club, User user) { - Club managedClub = entityManager.getReference(Club.class, club.getId()); - User managedUser = entityManager.getReference(User.class, user.getId()); - ClubMember clubMember = persist(ClubMember.builder() - .club(managedClub) - .user(managedUser) - .clubPosition(ClubPosition.MEMBER) - .build()); - clearPersistenceContext(); - return clubMember; - } + @Test + @DisplayName("그룹 채팅방을 생성하면 방장과 멤버가 모두 참여한다") + void createGroupChatRoomSuccess() throws Exception { + // given + mockLoginUser(normalUser.getId()); - private ChatMessage persistChatMessage(ChatRoom chatRoom, User sender, String content) { - ChatRoom managedChatRoom = entityManager.getReference(ChatRoom.class, chatRoom.getId()); - User managedSender = entityManager.getReference(User.class, sender.getId()); + // when + int roomId = objectMapper.readTree( + performPost("/chats/rooms/group", new ChatRoomCreateRequest.Group( + List.of(memberA.getId(), memberB.getId()) + )) + .andReturn().getResponse().getContentAsString() + ).get("chatRoomId").asInt(); + + // then - 채팅방 타입이 GROUP인지 확인 + clearPersistenceContext(); + ChatRoom chatRoom = chatRoomRepository.findById(roomId).orElseThrow(); + assertThat(chatRoom.getRoomType()).isEqualTo(ChatType.GROUP); - ChatMessage chatMessage = persist(ChatMessage.of(managedChatRoom, managedSender, content)); - managedChatRoom.updateLastMessage(chatMessage.getContent(), chatMessage.getCreatedAt()); - entityManager.flush(); - clearPersistenceContext(); - return chatMessage; - } + // then - 방장(owner) 확인 + ChatRoomMember ownerMember = chatRoomMemberRepository + .findByChatRoomIdAndUserId(roomId, normalUser.getId()).orElseThrow(); + assertThat(ownerMember.isOwner()).isTrue(); - private void addRoomMember(ChatRoom chatRoom, User user) { - ChatRoom managedChatRoom = entityManager.getReference(ChatRoom.class, chatRoom.getId()); - User managedUser = entityManager.getReference(User.class, user.getId()); - persist(ChatRoomMember.of(managedChatRoom, managedUser, chatRoom.getCreatedAt())); - } + // then - 일반 멤버 확인 + ChatRoomMember memberARecord = chatRoomMemberRepository + .findByChatRoomIdAndUserId(roomId, memberA.getId()).orElseThrow(); + assertThat(memberARecord.isOwner()).isFalse(); - private void createGroupedInviteCandidates(String clubName, String namePrefix, int count) { - Club club = persist(ClubFixture.create(university, clubName)); - Club managedClub = entityManager.getReference(Club.class, club.getId()); - User managedNormalUser = entityManager.getReference(User.class, normalUser.getId()); + ChatRoomMember memberBRecord = chatRoomMemberRepository + .findByChatRoomIdAndUserId(roomId, memberB.getId()).orElseThrow(); + assertThat(memberBRecord.isOwner()).isFalse(); - persist(ClubMember.builder() - .club(managedClub) - .user(managedNormalUser) - .clubPosition(ClubPosition.MEMBER) - .build()); + assertThat(chatRoomMemberRepository.findByChatRoomId(roomId)).hasSize(3); + } - ChatRoom groupRoom = persist(ChatRoom.clubGroupOf(club)); - addRoomMember(groupRoom, normalUser); + @Test + @DisplayName("userIds에 자신만 포함되면 400을 반환한다") + void createGroupChatRoomWithSelfOnlyFails() throws Exception { + // given + mockLoginUser(normalUser.getId()); - for (int index = 1; index <= count; index++) { - User candidate = createUser( - String.format("%s%02d", namePrefix, index), - String.format("202199%04d", index + count * 10) - ); - User managedCandidate = entityManager.getReference(User.class, candidate.getId()); - persist(ClubMember.builder() - .club(managedClub) - .user(managedCandidate) - .clubPosition(ClubPosition.MEMBER) - .build()); - addRoomMember(groupRoom, candidate); + // when & then + performPost("/chats/rooms/group", new ChatRoomCreateRequest.Group( + List.of(normalUser.getId()) + )) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("CANNOT_CREATE_CHAT_ROOM_WITH_SELF")); } - } - - private long countDirectRoomsBetween(User firstUser, User secondUser) { - return chatRoomRepository.findByUserId(firstUser.getId(), ChatType.DIRECT).stream() - .map(ChatRoom::getId) - .filter(roomId -> isDirectRoomBetween(roomId, firstUser.getId(), secondUser.getId())) - .count(); - } - private boolean isDirectRoomBetween(Integer roomId, Integer firstUserId, Integer secondUserId) { - List roomMembers = chatRoomMemberRepository.findByChatRoomId(roomId); - return roomMembers.size() == 2 - && roomMembers.stream().anyMatch(member -> member.getUserId().equals(firstUserId)) - && roomMembers.stream().anyMatch(member -> member.getUserId().equals(secondUserId)); + @Test + @DisplayName("userIds가 빈 리스트이면 validation 에러를 반환한다") + void createGroupChatRoomWithEmptyUserIdsFails() throws Exception { + // given + mockLoginUser(normalUser.getId()); + + // when & then + performPost("/chats/rooms/group", new ChatRoomCreateRequest.Group(List.of())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_REQUEST_BODY")); + } + + @Test + @DisplayName("존재하지 않는 userId가 포함되면 404를 반환한다") + void createGroupChatRoomWithNonExistentUserFails() throws Exception { + // given + mockLoginUser(normalUser.getId()); + + // when & then + performPost("/chats/rooms/group", new ChatRoomCreateRequest.Group( + List.of(memberA.getId(), 99999) + )) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("NOT_FOUND_USER")); + } + + @Test + @DisplayName("중복 userId는 무시하고 정상 생성한다") + void createGroupChatRoomWithDuplicateUserIds() throws Exception { + // given + mockLoginUser(normalUser.getId()); + + // when + int roomId = objectMapper.readTree( + performPost("/chats/rooms/group", new ChatRoomCreateRequest.Group( + List.of(memberA.getId(), memberA.getId(), memberB.getId()) + )) + .andReturn().getResponse().getContentAsString() + ).get("chatRoomId").asInt(); + + // then - 멤버 수가 중복 제거된 3명(방장 + A + B)이어야 한다 + clearPersistenceContext(); + assertThat(chatRoomMemberRepository.findByChatRoomId(roomId)).hasSize(3); + } + + @Test + @DisplayName("userIds에 자신이 포함되어도 무시하고 정상 생성한다") + void createGroupChatRoomWithSelfInUserIds() throws Exception { + // given + mockLoginUser(normalUser.getId()); + + // when + int roomId = objectMapper.readTree( + performPost("/chats/rooms/group", new ChatRoomCreateRequest.Group( + List.of(normalUser.getId(), memberA.getId()) + )) + .andReturn().getResponse().getContentAsString() + ).get("chatRoomId").asInt(); + + // then - 멤버 수가 2명(방장 + A)이어야 한다 + clearPersistenceContext(); + assertThat(chatRoomMemberRepository.findByChatRoomId(roomId)).hasSize(2); + } + + @Test + @DisplayName("초대받은 멤버도 그룹 채팅방에 메시지를 전송할 수 있다") + void invitedMemberCanSendMessageToGroupChatRoom() throws Exception { + // given + mockLoginUser(normalUser.getId()); + int roomId = objectMapper.readTree( + performPost("/chats/rooms/group", new ChatRoomCreateRequest.Group( + List.of(memberA.getId(), memberB.getId()) + )) + .andReturn().getResponse().getContentAsString() + ).get("chatRoomId").asInt(); + + // when & then - memberA(초대받은 멤버)가 메시지 전송 + mockLoginUser(memberA.getId()); + performPost( + "/chats/rooms/" + roomId + "/messages", + new ChatMessageSendRequest("초대받은 멤버의 메시지") + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.senderId").value(memberA.getId())) + .andExpect(jsonPath("$.senderName").value(memberA.getName())) + .andExpect(jsonPath("$.content").value("초대받은 멤버의 메시지")) + .andExpect(jsonPath("$.isMine").value(true)); + } + + @Test + @DisplayName("같은 유저들로 그룹 채팅방을 여러 개 생성할 수 있다") + void createMultipleGroupChatRoomsWithSameUsers() throws Exception { + // given + mockLoginUser(normalUser.getId()); + + // when - 같은 유저들로 첫 번째 그룹방 생성 + int firstRoomId = objectMapper.readTree( + performPost("/chats/rooms/group", new ChatRoomCreateRequest.Group( + List.of(memberA.getId(), memberB.getId()) + )) + .andReturn().getResponse().getContentAsString() + ).get("chatRoomId").asInt(); + + // when - 같은 유저들로 두 번째 그룹방 생성 + int secondRoomId = objectMapper.readTree( + performPost("/chats/rooms/group", new ChatRoomCreateRequest.Group( + List.of(memberA.getId(), memberB.getId()) + )) + .andReturn().getResponse().getContentAsString() + ).get("chatRoomId").asInt(); + + // then - 서로 다른 방이 생성됨 (DIRECT와 달리 중복 허용) + assertThat(secondRoomId).isNotEqualTo(firstRoomId); + clearPersistenceContext(); + assertThat(chatRoomRepository.findGroupRoomsByMemberUserId(normalUser.getId())).hasSize(2); + } + + @Test + @DisplayName("생성된 그룹 채팅방에 메시지를 전송할 수 있다") + void canSendMessageToCreatedGroupChatRoom() throws Exception { + // given + mockLoginUser(normalUser.getId()); + int roomId = objectMapper.readTree( + performPost("/chats/rooms/group", new ChatRoomCreateRequest.Group( + List.of(memberA.getId(), memberB.getId()) + )) + .andReturn().getResponse().getContentAsString() + ).get("chatRoomId").asInt(); + + // when & then + performPost( + "/chats/rooms/" + roomId + "/messages", + new ChatMessageSendRequest("그룹방 첫 메시지") + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.messageId").isNumber()) + .andExpect(jsonPath("$.senderId").value(normalUser.getId())) + .andExpect(jsonPath("$.senderName").value(normalUser.getName())) + .andExpect(jsonPath("$.content").value("그룹방 첫 메시지")) + .andExpect(jsonPath("$.isMine").value(true)); + } + } + + @Nested + @DisplayName("DELETE /chats/rooms/{chatRoomId}/members/{targetUserId} - 멤버 강퇴") + class KickMember { + + private User ownerUser; + private User memberUser; + private User anotherMember; + private ChatRoom groupRoom; + + @BeforeEach + void setUpKickFixture() { + ownerUser = createUser("방장", "2021136002"); + memberUser = createUser("멤버", "2021136003"); + anotherMember = createUser("다른멤버", "2021136004"); + groupRoom = createGroupChatRoomWithOwner(ownerUser, memberUser, anotherMember); + clearPersistenceContext(); + } + + @Test + @DisplayName("방장이 일반 멤버를 강퇴한다") + void kickMemberSuccess() throws Exception { + // given + mockLoginUser(ownerUser.getId()); + + // when & then + performDelete("/chats/rooms/" + groupRoom.getId() + "/members/" + memberUser.getId()) + .andExpect(status().isNoContent()); + + // then + clearPersistenceContext(); + assertThat(chatRoomMemberRepository.findByChatRoomIdAndUserId( + groupRoom.getId(), memberUser.getId() + )).isEmpty(); + assertThat(chatRoomMemberRepository.findByChatRoomIdAndUserId( + groupRoom.getId(), ownerUser.getId() + )).isPresent(); + assertThat(chatRoomMemberRepository.findByChatRoomId(groupRoom.getId())).hasSize(2); + } + + @Test + @DisplayName("방장이 아닌 멤버가 강퇴하면 403을 반환한다") + void kickMemberByNonOwnerFails() throws Exception { + // given + mockLoginUser(memberUser.getId()); + + // when & then + performDelete("/chats/rooms/" + groupRoom.getId() + "/members/" + anotherMember.getId()) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("FORBIDDEN_CHAT_ROOM_KICK")); + } + + @Test + @DisplayName("자기 자신을 강퇴하면 400을 반환한다") + void kickSelfFails() throws Exception { + // given + mockLoginUser(ownerUser.getId()); + + // when & then + performDelete("/chats/rooms/" + groupRoom.getId() + "/members/" + ownerUser.getId()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("CANNOT_KICK_SELF")); + } + + @Test + @DisplayName("1:1 채팅방에서 강퇴하면 400을 반환한다") + void kickInDirectRoomFails() throws Exception { + // given + ChatRoom directRoom = createDirectChatRoom(ownerUser, memberUser); + mockLoginUser(ownerUser.getId()); + + // when & then + performDelete("/chats/rooms/" + directRoom.getId() + "/members/" + memberUser.getId()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("CANNOT_KICK_IN_NON_GROUP_ROOM")); + } + + @Test + @DisplayName("동아리 채팅방에서 강퇴하면 400을 반환한다") + void kickInClubRoomFails() throws Exception { + // given + Club club = persist(ClubFixture.create(university)); + ChatRoom clubRoom = persist(ChatRoom.clubGroupOf(club)); + addRoomMember(clubRoom, ownerUser); + addRoomMember(clubRoom, memberUser); + clearPersistenceContext(); + mockLoginUser(ownerUser.getId()); + + // when & then + performDelete("/chats/rooms/" + clubRoom.getId() + "/members/" + memberUser.getId()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("CANNOT_KICK_IN_NON_GROUP_ROOM")); + } + + @Test + @DisplayName("다른 방장(owner) 멤버를 강퇴하면 400을 반환한다") + void kickAnotherOwnerFails() throws Exception { + // given - 방장 2명인 그룹 방을 수동 생성 + ChatRoom room = persist(ChatRoom.groupOf()); + ChatRoom managedRoom = entityManager.getReference(ChatRoom.class, room.getId()); + User managedOwner = entityManager.getReference(User.class, ownerUser.getId()); + User managedMember = entityManager.getReference(User.class, memberUser.getId()); + persist(ChatRoomMember.ofOwner(managedRoom, managedOwner, room.getCreatedAt())); + persist(ChatRoomMember.ofOwner(managedRoom, managedMember, room.getCreatedAt())); + clearPersistenceContext(); + mockLoginUser(ownerUser.getId()); + + // when & then + performDelete("/chats/rooms/" + room.getId() + "/members/" + memberUser.getId()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("CANNOT_KICK_ROOM_OWNER")); + } + + @Test + @DisplayName("채팅방에 없는 멤버를 강퇴하면 403을 반환한다") + void kickNonMemberTargetFails() throws Exception { + // given + User outsiderUser = createUser("외부인", "2021136005"); + clearPersistenceContext(); + mockLoginUser(ownerUser.getId()); + + // when & then + performDelete("/chats/rooms/" + groupRoom.getId() + "/members/" + outsiderUser.getId()) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("FORBIDDEN_CHAT_ROOM_ACCESS")); + } + + @Test + @DisplayName("강퇴된 멤버는 채팅방 이름을 수정할 수 없다") + void kickedMemberCannotUpdateRoomName() throws Exception { + // given + mockLoginUser(ownerUser.getId()); + performDelete("/chats/rooms/" + groupRoom.getId() + "/members/" + memberUser.getId()) + .andExpect(status().isNoContent()); + + // when & then + mockLoginUser(memberUser.getId()); + performPatch( + "/chats/rooms/" + groupRoom.getId() + "/name", + new ChatRoomNameUpdateRequest("강퇴 후 이름") + ) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("FORBIDDEN_CHAT_ROOM_ACCESS")); + } + + @Test + @DisplayName("강퇴된 멤버는 메시지를 보낼 수 없다") + void kickedMemberCannotSendMessage() throws Exception { + // given + Integer roomId = groupRoom.getId(); + mockLoginUser(ownerUser.getId()); + performDelete("/chats/rooms/" + roomId + "/members/" + memberUser.getId()) + .andExpect(status().isNoContent()); + + // when & then + mockLoginUser(memberUser.getId()); + performPost( + "/chats/rooms/" + roomId + "/messages", + new ChatMessageSendRequest("강퇴 후 메시지") + ) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("FORBIDDEN_CHAT_ROOM_ACCESS")); + } + + @Test + @DisplayName("강퇴된 멤버는 메시지를 조회할 수 없다") + @Sql( + statements = CHAT_TEST_DATA_CLEANUP_SQL, + config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED), + executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD + ) + void kickedMemberCannotGetMessages() throws Exception { + // given + Integer roomId = groupRoom.getId(); + mockLoginUser(ownerUser.getId()); + performDelete("/chats/rooms/" + roomId + "/members/" + memberUser.getId()) + .andExpect(status().isNoContent()); + + TestTransaction.flagForCommit(); + TestTransaction.end(); + + // when & then + mockLoginUser(memberUser.getId()); + performGet("/chats/rooms/" + roomId + "?page=1&limit=20") + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("FORBIDDEN_CHAT_ROOM_ACCESS")); + } + + @Test + @DisplayName("강퇴된 멤버의 방 목록에서 해당 방이 제거된다") + void kickedMemberRoomRemovedFromList() throws Exception { + // given + mockLoginUser(ownerUser.getId()); + performDelete("/chats/rooms/" + groupRoom.getId() + "/members/" + memberUser.getId()) + .andExpect(status().isNoContent()); + + // when & then + mockLoginUser(memberUser.getId()); + performGet("/chats/rooms") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.rooms[?(@.roomId==" + groupRoom.getId() + ")]").doesNotExist()); + } + + @Test + @DisplayName("존재하지 않는 채팅방에서 강퇴하면 404를 반환한다") + void kickInNonExistentRoomFails() throws Exception { + // given + mockLoginUser(ownerUser.getId()); + + // when & then + performDelete("/chats/rooms/99999/members/" + memberUser.getId()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("NOT_FOUND_CHAT_ROOM")); + } + + @Test + @DisplayName("채팅방 멤버가 아닌 사용자가 강퇴를 시도하면 403을 반환한다") + void kickByOutsiderRequesterFails() throws Exception { + // given + User outsiderUser = createUser("외부인", "2021136005"); + clearPersistenceContext(); + mockLoginUser(outsiderUser.getId()); + + // when & then + performDelete("/chats/rooms/" + groupRoom.getId() + "/members/" + memberUser.getId()) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("FORBIDDEN_CHAT_ROOM_ACCESS")); + } + + @Test + @DisplayName("이미 나간 멤버를 강퇴하면 403을 반환한다") + void kickAlreadyLeftMemberFails() throws Exception { + // given - member leaves the group room + mockLoginUser(memberUser.getId()); + performDelete("/chats/rooms/" + groupRoom.getId()) + .andExpect(status().isNoContent()); + + // when & then - owner tries to kick the left member + mockLoginUser(ownerUser.getId()); + performDelete("/chats/rooms/" + groupRoom.getId() + "/members/" + memberUser.getId()) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("FORBIDDEN_CHAT_ROOM_ACCESS")); + } + + @Test + @DisplayName("마지막 멤버를 강퇴하면 방에 방장만 남는다") + void kickLastMemberLeavesOwnerOnly() throws Exception { + // given - room with only owner and one member + User soleMember = createUser("유일멤버", "2021136005"); + ChatRoom twoPersonRoom = createGroupChatRoomWithOwner(ownerUser, soleMember); + clearPersistenceContext(); + + mockLoginUser(ownerUser.getId()); + + // when + performDelete("/chats/rooms/" + twoPersonRoom.getId() + "/members/" + soleMember.getId()) + .andExpect(status().isNoContent()); + + // then - only owner remains + clearPersistenceContext(); + assertThat(chatRoomMemberRepository.findByChatRoomId(twoPersonRoom.getId())).hasSize(1); + assertThat(chatRoomMemberRepository.findByChatRoomIdAndUserId(twoPersonRoom.getId(), ownerUser.getId())) + .isPresent() + .get() + .extracting(ChatRoomMember::isOwner) + .isEqualTo(true); + } + + @Test + @DisplayName("방장이 나간 그룹방에서 일반 멤버가 강퇴할 수 없다") + void kickFailsAfterOwnerLeaves() throws Exception { + // given - 3인 그룹방: owner + memberUser + anotherMember + // owner가 나가면 방장이 없는 상태가 됨 + mockLoginUser(ownerUser.getId()); + performDelete("/chats/rooms/" + groupRoom.getId()) + .andExpect(status().isNoContent()); + + // when & then - 남은 일반 멤버가 다른 멤버를 강퇴 시도 + mockLoginUser(memberUser.getId()); + performDelete("/chats/rooms/" + groupRoom.getId() + "/members/" + anotherMember.getId()) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("FORBIDDEN_CHAT_ROOM_KICK")); + } + } + + @Nested + @DisplayName("GET /chats/rooms/{chatRoomId} - 메시지 조회 페이지네이션 엣지 케이스") + @Sql( + statements = CHAT_TEST_DATA_CLEANUP_SQL, + config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED), + executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD + ) + class GetMessagesPaginationEdgeCases { + + private ChatRoom directRoom; + private User chatPartner; + + @BeforeEach + void setUpPaginationFixture() { + chatPartner = createUser("채팅상대", "2021136006"); + clearPersistenceContext(); + } + + @Test + @DisplayName("빈 채팅방의 메시지를 조회하면 빈 목록을 반환한다") + void getMessagesFromEmptyRoomReturnsEmptyList() throws Exception { + // given - 메시지가 없는 새로 생성된 방 + directRoom = createDirectChatRoom(normalUser, chatPartner); + TestTransaction.flagForCommit(); + TestTransaction.end(); + + mockLoginUser(normalUser.getId()); + + // when & then + performGet("/chats/rooms/" + directRoom.getId()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.messages").isArray()) + .andExpect(jsonPath("$.messages").isEmpty()) + .andExpect(jsonPath("$.totalCount").value(0)) + .andExpect(jsonPath("$.currentPage").value(1)) + .andExpect(jsonPath("$.totalPage").value(0)); + } + + @Test + @DisplayName("존재하지 않는 페이지를 조회하면 빈 목록을 반환한다") + void getMessagesFromNonExistentPageReturnsEmptyList() throws Exception { + // given - 메시지 5개 생성 + directRoom = createDirectChatRoom(normalUser, chatPartner); + for (int i = 1; i <= 5; i++) { + persistChatMessage(directRoom, chatPartner, "메시지 " + i); + } + TestTransaction.flagForCommit(); + TestTransaction.end(); + + mockLoginUser(normalUser.getId()); + + // when & then - 100페이지 조회 (존재하지 않음) + performGet("/chats/rooms/" + directRoom.getId() + "?page=100&limit=20") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.messages").isArray()) + .andExpect(jsonPath("$.messages").isEmpty()) + .andExpect(jsonPath("$.currentPage").value(100)) + .andExpect(jsonPath("$.totalPage").value(1)); + } + + @Test + @DisplayName("마지막 페이지는 부분 결과를 반환할 수 있다") + void getMessagesLastPageReturnsPartialResults() throws Exception { + // given - 메시지 25개 생성 + directRoom = createDirectChatRoom(normalUser, chatPartner); + for (int i = 1; i <= 25; i++) { + persistChatMessage(directRoom, chatPartner, "메시지 " + i); + } + TestTransaction.flagForCommit(); + TestTransaction.end(); + + mockLoginUser(normalUser.getId()); + + // when & then - limit=10, page=3 (21-25번 메시지, 5개만 반환) + performGet("/chats/rooms/" + directRoom.getId() + "?page=3&limit=10") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.messages").isArray()) + .andExpect(jsonPath("$.messages").value(org.hamcrest.Matchers.hasSize(5))) + .andExpect(jsonPath("$.currentPage").value(3)) + .andExpect(jsonPath("$.totalPage").value(3)); + } + } + + @Nested + @DisplayName("POST /chats/rooms/{chatRoomId}/messages - 메시지 전송 권한 및 엣지 케이스") + @Sql( + statements = CHAT_TEST_DATA_CLEANUP_SQL, + config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED), + executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD + ) + class SendMessageEdgeCases { + + private ChatRoom groupRoom; + private User roomOwner; + private User roomMember; + private User nonMember; + + @BeforeEach + void setUpSendMessageEdgeFixture() { + roomOwner = createUser("방장", "2021136007"); + roomMember = createUser("방멤버", "2021136008"); + nonMember = createUser("비멤버", "2021136009"); + clearPersistenceContext(); + } + + @Test + @DisplayName("정확히 1000자 메시지는 전송 성공한다") + void sendMessageExactly1000CharsSuccess() throws Exception { + // given + groupRoom = createGroupChatRoomWithOwner(roomOwner, roomMember); + String exactly1000Chars = "a".repeat(1000); + TestTransaction.flagForCommit(); + TestTransaction.end(); + + mockLoginUser(roomMember.getId()); + + // when & then + performPost("/chats/rooms/" + groupRoom.getId() + "/messages", + new ChatMessageSendRequest(exactly1000Chars)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").value(exactly1000Chars)); + } + + @Test + @DisplayName("채팅방 멤버가 아닌 사용자는 메시지를 전송할 수 없다") + void sendMessageByNonMemberReturnsForbidden() throws Exception { + // given + groupRoom = createGroupChatRoomWithOwner(roomOwner, roomMember); + TestTransaction.flagForCommit(); + TestTransaction.end(); + + mockLoginUser(nonMember.getId()); + + // when & then + performPost("/chats/rooms/" + groupRoom.getId() + "/messages", + new ChatMessageSendRequest("Unauthorized message")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("FORBIDDEN_CHAT_ROOM_ACCESS")); + } + + @Test + @DisplayName("강퇴된 멤버는 메시지를 전송할 수 없다") + void sendMessageByKickedMemberReturnsForbidden() throws Exception { + // given - 방장이 멤버를 강퇴 + groupRoom = createGroupChatRoomWithOwner(roomOwner, roomMember); + TestTransaction.flagForCommit(); + TestTransaction.end(); + + mockLoginUser(roomOwner.getId()); + performDelete("/chats/rooms/" + groupRoom.getId() + "/members/" + roomMember.getId()) + .andExpect(status().isNoContent()); + + // when & then - 강퇴된 멤버가 메시지 전송 시도 + mockLoginUser(roomMember.getId()); + performPost("/chats/rooms/" + groupRoom.getId() + "/messages", + new ChatMessageSendRequest("Kicked member message")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("FORBIDDEN_CHAT_ROOM_ACCESS")); + } + } + + @Nested + @DisplayName("POST /chats/rooms/{chatRoomId}/mute - 채팅방 뮤트 권한 케이스") + @Sql( + statements = CHAT_TEST_DATA_CLEANUP_SQL, + config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED), + executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD + ) + class ToggleMutePermissionCases { + + private ChatRoom groupRoom; + private User roomOwner; + private User roomMember; + private User nonMember; + + @BeforeEach + void setUpMutePermissionFixture() { + roomOwner = createUser("방장", "2021136010"); + roomMember = createUser("방멤버", "2021136011"); + nonMember = createUser("비멤버", "2021136012"); + clearPersistenceContext(); + } + + @Test + @DisplayName("채팅방 멤버가 아닌 사용자는 뮤트 설정을 변경할 수 없다") + void toggleMuteByNonMemberReturnsForbidden() throws Exception { + // given + groupRoom = createGroupChatRoomWithOwner(roomOwner, roomMember); + TestTransaction.flagForCommit(); + TestTransaction.end(); + + mockLoginUser(nonMember.getId()); + + // when & then + performPost("/chats/rooms/" + groupRoom.getId() + "/mute") + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("FORBIDDEN_CHAT_ROOM_ACCESS")); + } + + @Test + @DisplayName("강퇴된 멤버는 뮤트 설정을 변경할 수 없다") + void toggleMuteByKickedMemberReturnsForbidden() throws Exception { + // given - 방장이 멤버를 강퇴 + groupRoom = createGroupChatRoomWithOwner(roomOwner, roomMember); + TestTransaction.flagForCommit(); + TestTransaction.end(); + + mockLoginUser(roomOwner.getId()); + performDelete("/chats/rooms/" + groupRoom.getId() + "/members/" + roomMember.getId()) + .andExpect(status().isNoContent()); + + // when & then - 강퇴된 멤버가 뮤트 토글 시도 + mockLoginUser(roomMember.getId()); + performPost("/chats/rooms/" + groupRoom.getId() + "/mute") + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("FORBIDDEN_CHAT_ROOM_ACCESS")); + } + } + + @Nested + @DisplayName("GET /chats/rooms/search - 채팅 검색 엣지 케이스") + class SearchChatsEdgeCases { + + private ChatRoom directRoom; + private User chatPartner; + + @BeforeEach + void setUpSearchEdgeFixture() { + chatPartner = createUser("검색상대", "2021136013"); + directRoom = createDirectChatRoom(normalUser, chatPartner); + persistChatMessage(directRoom, chatPartner, "검색 가능한 메시지"); + clearPersistenceContext(); + } + + @Test + @DisplayName("검색 결과가 없으면 빈 목록을 반환한다") + void searchChatsWithNoMatchesReturnsEmpty() throws Exception { + // given + mockLoginUser(normalUser.getId()); + + // when & then - 존재하지 않는 키워드로 검색 + performGet("/chats/rooms/search?keyword=존재하지않는키워드12345&page=1&limit=20") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.roomMatches.rooms").isArray()) + .andExpect(jsonPath("$.roomMatches.rooms").isEmpty()) + .andExpect(jsonPath("$.messageMatches.messages").isArray()) + .andExpect(jsonPath("$.messageMatches.messages").isEmpty()); + } + + @Test + @DisplayName("한 글자 키워드로 검색해도 200을 반환한다") + void searchChatsWithSingleCharacterKeywordReturnsOk() throws Exception { + // given + mockLoginUser(normalUser.getId()); + + // when & then - 1글자 키워드로 검색 + performGet("/chats/rooms/search?keyword=a&page=1&limit=20") + .andExpect(status().isOk()); + } } } diff --git a/src/test/java/gg/agit/konect/integration/domain/club/ClubMemberApiTest.java b/src/test/java/gg/agit/konect/integration/domain/club/ClubMemberApiTest.java index 77f19ce6c..99d8fb02e 100644 --- a/src/test/java/gg/agit/konect/integration/domain/club/ClubMemberApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/club/ClubMemberApiTest.java @@ -2,16 +2,22 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.hasSize; +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; +import java.util.ArrayList; +import java.util.List; + 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 gg.agit.konect.domain.club.dto.ClubPreMemberAddRequest; +import gg.agit.konect.domain.club.dto.ClubPreMemberBatchAddRequest; import gg.agit.konect.domain.club.dto.MemberPositionChangeRequest; import gg.agit.konect.domain.club.dto.PresidentTransferRequest; import gg.agit.konect.domain.club.dto.VicePresidentChangeRequest; @@ -38,6 +44,7 @@ class ClubMemberApiTest extends IntegrationTestSupport { private User vicePresident; private User manager; private User member; + private User admin; @BeforeEach void setUp() throws Exception { @@ -47,6 +54,7 @@ void setUp() throws Exception { vicePresident = persist(UserFixture.createUser(university, "부회장", "2020000002")); manager = persist(UserFixture.createUser(university, "매니저", "2020000003")); member = persist(UserFixture.createUser(university, "일반멤버", "2021136001")); + admin = persist(UserFixture.createAdmin(university)); persist(ClubMemberFixture.createPresident(club, president)); persist(ClubMemberFixture.createVicePresident(club, vicePresident)); @@ -73,6 +81,21 @@ void changeMemberToManagerByPresident() throws Exception { .andExpect(jsonPath("$.position").value("MANAGER")); } + @Test + @DisplayName("관리자는 동아리 회원이 아니어도 멤버 직책을 변경할 수 있다") + void adminCanChangeMemberPositionWithoutClubMembership() throws Exception { + // given + clearPersistenceContext(); + mockLoginUser(admin.getId()); + + MemberPositionChangeRequest request = new MemberPositionChangeRequest(ClubPosition.MANAGER); + + // when & then + performPatch("/clubs/" + club.getId() + "/members/" + member.getId() + "/position", request) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.position").value("MANAGER")); + } + @Test @DisplayName("일반 멤버가 직책 변경을 시도하면 403을 반환한다") void changeMemberPositionByMemberFails() throws Exception { @@ -347,6 +370,166 @@ void addPreMemberByMemberFails() throws Exception { } } + @Nested + @DisplayName("POST /clubs/{clubId}/pre-members/batch - 사전 멤버 일괄 등록") + class AddPreMembersBatch { + + @Test + @DisplayName("여러 명의 사전 멤버를 일괄 등록한다") + void addPreMembersBatchSuccess() throws Exception { + // given + clearPersistenceContext(); + mockLoginUser(president.getId()); + + String requestBody = """ + {"members": [ + {"studentNumber": "2022000001", "name": "신입가", "clubPosition": "MEMBER"}, + {"studentNumber": "2022000002", "name": "신입나", "clubPosition": "MEMBER"}, + {"studentNumber": "2022000003", "name": "신입다", "clubPosition": "MANAGER"} + ]} + """; + + // when & then + mockMvc.perform(post("/clubs/" + club.getId() + "/pre-members/batch") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalCount").value(3)) + .andExpect(jsonPath("$.successCount").value(3)) + .andExpect(jsonPath("$.failedCount").value(0)) + .andExpect(jsonPath("$.results", hasSize(3))) + .andExpect(jsonPath("$.results[0].success").value(true)) + .andExpect(jsonPath("$.results[1].success").value(true)) + .andExpect(jsonPath("$.results[2].success").value(true)); + } + + @Test + @DisplayName("일부 실패 시에도 나머지는 등록된다 (부분 성공)") + void addPreMembersBatchPartialSuccess() throws Exception { + // given - 이미 존재하는 사용자를 먼저 사전 등록 + User existingUser = persist(UserFixture.createUser(university, "기존유저", "2022000002")); + clearPersistenceContext(); + mockLoginUser(president.getId()); + + // 먼저 첫 번째 멤버를 등록하여 중복 상황 만들기 + String firstRequest = """ + {"members": [{"studentNumber": "%s", "name": "%s", "clubPosition": "MEMBER"}]} + """.formatted(existingUser.getStudentNumber(), existingUser.getName()); + mockMvc.perform(post("/clubs/" + club.getId() + "/pre-members/batch") + .contentType(MediaType.APPLICATION_JSON) + .content(firstRequest)); + clearPersistenceContext(); + mockLoginUser(president.getId()); + + // 같은 멤버를 다시 배치 등록 시도 (첫 번째는 이미 존재해서 실패, 두 번째는 성공) + String batchRequest = """ + {"members": [ + {"studentNumber": "%s", "name": "%s", "clubPosition": "MEMBER"}, + {"studentNumber": "2022000003", "name": "신입생", "clubPosition": "MEMBER"} + ]} + """.formatted(existingUser.getStudentNumber(), existingUser.getName()); + + // when & then + mockMvc.perform(post("/clubs/" + club.getId() + "/pre-members/batch") + .contentType(MediaType.APPLICATION_JSON) + .content(batchRequest)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalCount").value(2)) + .andExpect(jsonPath("$.successCount").value(1)) + .andExpect(jsonPath("$.failedCount").value(1)) + .andExpect(jsonPath("$.results[0].success").value(false)) + .andExpect(jsonPath("$.results[1].success").value(true)); + } + + @Test + @DisplayName("300명 초과 요청 시 400을 반환한다") + void addPreMembersBatchExceedsLimit() throws Exception { + // given + clearPersistenceContext(); + mockLoginUser(president.getId()); + + List members = new ArrayList<>(); + for (int i = 0; i < 301; i++) { + members.add( + new ClubPreMemberAddRequest("2022" + String.format("%06d", i), "학생" + i, ClubPosition.MEMBER)); + } + ClubPreMemberBatchAddRequest request = new ClubPreMemberBatchAddRequest(members); + + // when & then + performPost("/clubs/" + club.getId() + "/pre-members/batch", request) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("일반 멤버는 배치 등록을 할 수 없다") + void addPreMembersBatchByMemberFails() throws Exception { + // given + clearPersistenceContext(); + mockLoginUser(member.getId()); + + List members = List.of( + new ClubPreMemberAddRequest("2022000001", "신입생", ClubPosition.MEMBER) + ); + ClubPreMemberBatchAddRequest request = new ClubPreMemberBatchAddRequest(members); + + // when & then + performPost("/clubs/" + club.getId() + "/pre-members/batch", request) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("빈 리스트 요청 시 400을 반환한다") + void addPreMembersBatchEmptyListFails() throws Exception { + // given + clearPersistenceContext(); + mockLoginUser(president.getId()); + + ClubPreMemberBatchAddRequest request = new ClubPreMemberBatchAddRequest(List.of()); + + // when & then + performPost("/clubs/" + club.getId() + "/pre-members/batch", request) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("null 리스트 요청 시 400을 반환한다") + void addPreMembersBatchNullListFails() throws Exception { + // given + clearPersistenceContext(); + mockLoginUser(president.getId()); + + // JSON에서 members를 null로 설정하여 요청 + String requestBody = """ + {"members": null} + """; + + // when & then + mockMvc.perform(post("/clubs/" + club.getId() + "/pre-members/batch") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("null 요소가 포함된 리스트는 400을 반환한다") + void addPreMembersBatchNullElementFails() throws Exception { + // given + clearPersistenceContext(); + mockLoginUser(president.getId()); + + // 리스트에 null 요소가 포함된 요청 + String requestBody = """ + {"members": [null]} + """; + + // when & then + mockMvc.perform(post("/clubs/" + club.getId() + "/pre-members/batch") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isBadRequest()); + } + } + @Nested @DisplayName("GET /clubs/{clubId}/pre-members - 사전 멤버 조회") class GetPreMembers { diff --git a/src/test/java/gg/agit/konect/integration/domain/club/ClubMemberApplicationsApiTest.java b/src/test/java/gg/agit/konect/integration/domain/club/ClubMemberApplicationsApiTest.java index 42bca00c9..b023000cf 100644 --- a/src/test/java/gg/agit/konect/integration/domain/club/ClubMemberApplicationsApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/club/ClubMemberApplicationsApiTest.java @@ -167,7 +167,7 @@ void getApprovedMemberApplicationsByNonMemberFails() throws Exception { } @Nested - @DisplayName("GET /clubs/{clubId}/member-applications/{userId}/answers - 특정 멤버 지원서 답변 조회") + @DisplayName("GET /clubs/{clubId}/member-applications/users/{userId} - 특정 멤버 지원서 답변 조회") class GetApprovedMemberApplicationAnswers { @Test @@ -193,9 +193,10 @@ void getApprovedMemberApplicationAnswersSuccess() throws Exception { mockLoginUser(president.getId()); // when & then - performGet("/clubs/" + club.getId() + "/member-applications/" + approvedUser.getId() + "/answers") + performGet("/clubs/" + club.getId() + "/member-applications/users/" + approvedUser.getId()) .andExpect(status().isOk()) - .andExpect(jsonPath("$.userId").value(approvedUser.getId())) + .andExpect(jsonPath("$.applicationId").value(approvedApply.getId())) + .andExpect(jsonPath("$.studentNumber").value(approvedUser.getStudentNumber())) .andExpect(jsonPath("$.name").value("승인자")) .andExpect(jsonPath("$.answers", hasSize(2))) .andExpect(jsonPath("$.answers[0].question").exists()) @@ -217,7 +218,7 @@ void getApprovedMemberApplicationAnswersWithoutQuestions() throws Exception { mockLoginUser(president.getId()); // when & then - performGet("/clubs/" + club.getId() + "/member-applications/" + approvedUser.getId() + "/answers") + performGet("/clubs/" + club.getId() + "/member-applications/users/" + approvedUser.getId()) .andExpect(status().isOk()) .andExpect(jsonPath("$.answers", hasSize(0))); } @@ -233,8 +234,8 @@ void getPendingMemberApplicationAnswersFails() throws Exception { mockLoginUser(president.getId()); // when & then - performGet("/clubs/" + club.getId() + "/member-applications/" + pendingUser.getId() + "/answers") - .andExpect(status().isBadRequest()); + performGet("/clubs/" + club.getId() + "/member-applications/users/" + pendingUser.getId()) + .andExpect(status().isNotFound()); } @Test @@ -247,8 +248,8 @@ void getNonMemberApplicationAnswersFails() throws Exception { mockLoginUser(president.getId()); // when & then - performGet("/clubs/" + club.getId() + "/member-applications/" + nonMember.getId() + "/answers") - .andExpect(status().isBadRequest()); + performGet("/clubs/" + club.getId() + "/member-applications/users/" + nonMember.getId()) + .andExpect(status().isNotFound()); } @Test @@ -278,7 +279,7 @@ void questionsModifiedAfterApplicationAreNotShown() throws Exception { mockLoginUser(president.getId()); // when & then - performGet("/clubs/" + club.getId() + "/member-applications/" + approvedUser.getId() + "/answers") + performGet("/clubs/" + club.getId() + "/member-applications/users/" + approvedUser.getId()) .andExpect(status().isOk()) .andExpect(jsonPath("$.answers", hasSize(1))) .andExpect(jsonPath("$.answers[0].question").value("구버전 질문")); diff --git a/src/test/java/gg/agit/konect/integration/domain/club/ClubSettingsControllerTest.java b/src/test/java/gg/agit/konect/integration/domain/club/ClubSettingsControllerTest.java index daf441b1c..41ceade11 100644 --- a/src/test/java/gg/agit/konect/integration/domain/club/ClubSettingsControllerTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/club/ClubSettingsControllerTest.java @@ -3,16 +3,10 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import java.util.stream.Stream; - 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.TestInstance; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; import org.springframework.test.web.servlet.ResultActions; import gg.agit.konect.domain.club.dto.ClubSettingsUpdateRequest; @@ -29,7 +23,7 @@ @DisplayName("ClubSettingsController 통합 테스트") class ClubSettingsControllerTest extends IntegrationTestSupport { - private static final long NON_EXISTENT_ID = Long.MAX_VALUE; + private static final int NON_EXISTENT_ID = Integer.MAX_VALUE; private University university; private User president; @@ -61,35 +55,45 @@ void setUp() throws Exception { @Nested @DisplayName("GET /clubs/{clubId}/settings - 동아리 설정 조회") - @TestInstance(TestInstance.Lifecycle.PER_CLASS) class GetSettings { - @ParameterizedTest(name = "{0} 권한으로 설정 조회 시 {2}를 반환한다") - @MethodSource("getSettingsAccessCases") - void getSettingsByRole(String roleName, Integer userId, int expectedStatus, boolean verifyDetailedPayload) - throws Exception { - mockLoginUser(userId); + @Test + @DisplayName("회장 권한으로 설정 조회 시 200을 반환한다") + void getSettingsAsPresident() throws Exception { + ResultActions result = performSettingsGetAndAssertRecruitmentEnabled(president.getId()); + assertPresidentSettingsPayload(result); + } + + @Test + @DisplayName("부회장 권한으로 설정 조회 시 200을 반환한다") + void getSettingsAsVicePresident() throws Exception { + ResultActions result = performSettingsGetAndAssertRecruitmentEnabled(vicePresident.getId()); + assertPresidentSettingsPayload(result); + } - ResultActions result = performGet("/clubs/" + club.getId() + "/settings") - .andExpect(status().is(expectedStatus)); + @Test + @DisplayName("운영진 권한으로 설정 조회 시 200을 반환한다") + void getSettingsAsManager() throws Exception { + ResultActions result = performSettingsGetAndAssertRecruitmentEnabled(manager.getId()); + assertPresidentSettingsPayload(result); + } - if (expectedStatus == 200) { - result.andExpect(jsonPath("$.isRecruitmentEnabled").value(true)); - } + @Test + @DisplayName("일반 회원 권한으로 설정 조회 시 403을 반환한다") + void getSettingsAsRegularMemberFails() throws Exception { + mockLoginUser(regularMember.getId()); - if (verifyDetailedPayload) { - assertPresidentSettingsPayload(result); - } + performGet("/clubs/" + club.getId() + "/settings") + .andExpect(status().isForbidden()); } - Stream getSettingsAccessCases() { - return Stream.of( - Arguments.of("회장", president.getId(), 200, true), - Arguments.of("부회장", vicePresident.getId(), 200, false), - Arguments.of("운영진", manager.getId(), 200, false), - Arguments.of("일반 회원", regularMember.getId(), 403, false), - Arguments.of("비회원", nonMember.getId(), 403, false) - ); + @Test + @DisplayName("비회원 권한으로 설정 조회 시 403을 반환한다") + void getSettingsAsNonMemberFails() throws Exception { + mockLoginUser(nonMember.getId()); + + performGet("/clubs/" + club.getId() + "/settings") + .andExpect(status().isForbidden()); } private void assertPresidentSettingsPayload(ResultActions result) throws Exception { @@ -101,6 +105,14 @@ private void assertPresidentSettingsPayload(ResultActions result) throws Excepti .andExpect(jsonPath("$.fee").doesNotExist()); } + private ResultActions performSettingsGetAndAssertRecruitmentEnabled(Integer userId) throws Exception { + mockLoginUser(userId); + + return performGet("/clubs/" + club.getId() + "/settings") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isRecruitmentEnabled").value(true)); + } + @Test @DisplayName("존재하지 않는 동아리 조회 시 404를 반환한다") void getSettingsNotFoundClub() throws Exception { diff --git a/src/test/java/gg/agit/konect/integration/domain/club/ClubSheetMigrationApiTest.java b/src/test/java/gg/agit/konect/integration/domain/club/ClubSheetMigrationApiTest.java index b6de3f6ea..d38987caa 100644 --- a/src/test/java/gg/agit/konect/integration/domain/club/ClubSheetMigrationApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/club/ClubSheetMigrationApiTest.java @@ -13,15 +13,22 @@ import org.junit.jupiter.api.Test; import org.springframework.test.context.bean.override.mockito.MockitoBean; +import gg.agit.konect.domain.club.dto.SheetImportConfirmRequest; +import gg.agit.konect.domain.club.dto.SheetImportPreviewResponse; import gg.agit.konect.domain.club.dto.SheetImportRequest; import gg.agit.konect.domain.club.dto.SheetImportResponse; +import gg.agit.konect.domain.club.enums.ClubPosition; import gg.agit.konect.domain.club.service.ClubSheetIntegratedService; +import gg.agit.konect.domain.club.service.SheetImportService; import gg.agit.konect.global.code.ApiResponseCode; import gg.agit.konect.global.exception.CustomException; import gg.agit.konect.support.IntegrationTestSupport; class ClubSheetMigrationApiTest extends IntegrationTestSupport { + @MockitoBean + private SheetImportService sheetImportService; + @MockitoBean private ClubSheetIntegratedService clubSheetIntegratedService; @@ -36,11 +43,176 @@ void setUp() throws Exception { } @Nested - @DisplayName("POST /clubs/{clubId}/sheet/import/integrated - 시트 통합 가져오기") + @DisplayName("POST /clubs/{clubId}/sheet/import/preview") + class PreviewPreMembers { + + @Test + @DisplayName("returns preview member list") + void previewPreMembersSuccess() throws Exception { + SheetImportPreviewResponse response = SheetImportPreviewResponse.of( + List.of( + new SheetImportPreviewResponse.PreviewMember( + "2021232948", + "Kim Manager", + ClubPosition.MANAGER, + true, + true + ), + new SheetImportPreviewResponse.PreviewMember( + "2021232949", + "Lee Member", + ClubPosition.MEMBER, + false, + true + ) + ), + List.of("전화번호 형식 경고") + ); + + given(sheetImportService.previewPreMembersFromSheet( + eq(CLUB_ID), + eq(REQUESTER_ID) + )).willReturn(response); + + performPost("/clubs/" + CLUB_ID + "/sheet/import/preview") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.previewCount").value(2)) + .andExpect(jsonPath("$.autoRegisteredCount").value(1)) + .andExpect(jsonPath("$.preRegisteredCount").value(1)) + .andExpect(jsonPath("$.members[0].studentNumber").value("2021232948")) + .andExpect(jsonPath("$.members[0].isDirectMember").value(true)) + .andExpect(jsonPath("$.members[0].enabled").value(true)) + .andExpect(jsonPath("$.members[1].studentNumber").value("2021232949")) + .andExpect(jsonPath("$.members[1].isDirectMember").value(false)) + .andExpect(jsonPath("$.members[1].enabled").value(true)) + .andExpect(jsonPath("$.warnings[0]").value("전화번호 형식 경고")); + } + + @Test + @DisplayName("returns 403 when sheet access is forbidden") + void previewPreMembersForbiddenGoogleSheetAccess() throws Exception { + given(sheetImportService.previewPreMembersFromSheet( + eq(CLUB_ID), + eq(REQUESTER_ID) + )).willThrow(CustomException.of(ApiResponseCode.FORBIDDEN_GOOGLE_SHEET_ACCESS)); + + performPost("/clubs/" + CLUB_ID + "/sheet/import/preview") + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code") + .value(ApiResponseCode.FORBIDDEN_GOOGLE_SHEET_ACCESS.name())) + .andExpect(jsonPath("$.message") + .value(ApiResponseCode.FORBIDDEN_GOOGLE_SHEET_ACCESS.getMessage())); + } + + @Test + @DisplayName("returns 400 when sheet analysis and registration are not completed") + void previewPreMembersRequiresRegisteredSheet() throws Exception { + given(sheetImportService.previewPreMembersFromSheet( + eq(CLUB_ID), + eq(REQUESTER_ID) + )).willThrow(CustomException.of(ApiResponseCode.CLUB_SHEET_ANALYSIS_REQUIRED)); + + performPost("/clubs/" + CLUB_ID + "/sheet/import/preview") + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code") + .value(ApiResponseCode.CLUB_SHEET_ANALYSIS_REQUIRED.name())) + .andExpect(jsonPath("$.message") + .value(ApiResponseCode.CLUB_SHEET_ANALYSIS_REQUIRED.getMessage())); + } + } + + @Nested + @DisplayName("POST /clubs/{clubId}/sheet/import/confirm") + class ConfirmImportPreMembers { + + @Test + @DisplayName("imports only enabled preview members") + void confirmImportPreMembersSuccess() throws Exception { + given(sheetImportService.confirmImportPreMembers( + eq(CLUB_ID), + eq(REQUESTER_ID), + eq(List.of( + new SheetImportConfirmRequest.ConfirmMember( + "2021232948", + "Kim Manager", + ClubPosition.MANAGER, + true + ), + new SheetImportConfirmRequest.ConfirmMember( + "2021232949", + "Lee Member", + ClubPosition.MEMBER, + false + ), + new SheetImportConfirmRequest.ConfirmMember( + "2021232950", + "Park Member", + ClubPosition.MEMBER, + true + ) + )) + )).willReturn(SheetImportResponse.of(1, 1, List.of())); + + SheetImportConfirmRequest request = new SheetImportConfirmRequest(List.of( + new SheetImportConfirmRequest.ConfirmMember( + "2021232948", + "Kim Manager", + ClubPosition.MANAGER, + true + ), + new SheetImportConfirmRequest.ConfirmMember( + "2021232949", + "Lee Member", + ClubPosition.MEMBER, + false + ), + new SheetImportConfirmRequest.ConfirmMember( + "2021232950", + "Park Member", + ClubPosition.MEMBER, + true + ) + )); + + performPost("/clubs/" + CLUB_ID + "/sheet/import/confirm", request) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.importedCount").value(1)) + .andExpect(jsonPath("$.autoRegisteredCount").value(1)); + } + + @Test + @DisplayName("returns 403 when confirm import access is forbidden") + void confirmImportPreMembersForbidden() throws Exception { + SheetImportConfirmRequest request = new SheetImportConfirmRequest(List.of( + new SheetImportConfirmRequest.ConfirmMember( + "2021232948", + "Kim Manager", + ClubPosition.MANAGER, + true + ) + )); + + given(sheetImportService.confirmImportPreMembers( + eq(CLUB_ID), + eq(REQUESTER_ID), + eq(request.members()) + )).willThrow(CustomException.of(ApiResponseCode.FORBIDDEN_CLUB_MANAGER_ACCESS)); + + performPost("/clubs/" + CLUB_ID + "/sheet/import/confirm", request) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code") + .value(ApiResponseCode.FORBIDDEN_CLUB_MANAGER_ACCESS.name())) + .andExpect(jsonPath("$.message") + .value(ApiResponseCode.FORBIDDEN_CLUB_MANAGER_ACCESS.getMessage())); + } + } + + @Nested + @DisplayName("POST /clubs/{clubId}/sheet/import/integrated") class AnalyzeAndImportPreMembers { @Test - @DisplayName("시트 분석 등록 후 사전 회원 가져오기 결과를 반환한다") + @DisplayName("returns integrated import result") void analyzeAndImportPreMembersSuccess() throws Exception { given(clubSheetIntegratedService.analyzeAndImportPreMembers( eq(CLUB_ID), @@ -58,7 +230,7 @@ void analyzeAndImportPreMembersSuccess() throws Exception { } @Test - @DisplayName("구글 스프레드시트 403 오류를 response body로 반환한다") + @DisplayName("returns 403 response body for forbidden sheet access") void analyzeAndImportPreMembersForbiddenGoogleSheetAccess() throws Exception { given(clubSheetIntegratedService.analyzeAndImportPreMembers( eq(CLUB_ID), @@ -77,7 +249,7 @@ void analyzeAndImportPreMembersForbiddenGoogleSheetAccess() throws Exception { } @Test - @DisplayName("Google Drive invalid_grant 400 오류를 response body detail로 반환한다") + @DisplayName("returns detail for invalid Google Drive auth") void analyzeAndImportPreMembersInvalidGoogleDriveAuth() throws Exception { String detail = "400 Bad Request\nPOST https://oauth2.googleapis.com/token\n{\"error\":\"invalid_grant\"}"; diff --git a/src/test/java/gg/agit/konect/integration/domain/council/CouncilApiTest.java b/src/test/java/gg/agit/konect/integration/domain/council/CouncilApiTest.java new file mode 100644 index 000000000..4595cd1a5 --- /dev/null +++ b/src/test/java/gg/agit/konect/integration/domain/council/CouncilApiTest.java @@ -0,0 +1,282 @@ +package gg.agit.konect.integration.domain.council; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +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 gg.agit.konect.domain.council.dto.CouncilCreateRequest; +import gg.agit.konect.domain.council.dto.CouncilUpdateRequest; +import gg.agit.konect.domain.council.model.Council; +import gg.agit.konect.domain.university.model.University; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.support.IntegrationTestSupport; +import gg.agit.konect.support.fixture.CouncilFixture; +import gg.agit.konect.support.fixture.UniversityFixture; +import gg.agit.konect.support.fixture.UserFixture; + +class CouncilApiTest extends IntegrationTestSupport { + + private static final String COUNCILS_ENDPOINT = "/councils"; + + private University university; + private User user; + + @BeforeEach + void setUp() throws Exception { + university = persist(UniversityFixture.create()); + user = persist(UserFixture.createUser(university, "학생회유저", "2021136001")); + clearPersistenceContext(); + mockLoginUser(user.getId()); + } + + @Nested + @DisplayName("GET /councils - 총동아리연합회 조회") + class GetCouncil { + + @Test + @DisplayName("사용자 대학의 총동아리연합회 정보를 조회한다") + void getCouncilSuccess() throws Exception { + // given + Council council = persist(CouncilFixture.create(university)); + clearPersistenceContext(); + + // when & then + performGet(COUNCILS_ENDPOINT) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(council.getId())) + .andExpect(jsonPath("$.name").value("총학생회")) + .andExpect(jsonPath("$.instagramUserName").value("koreatech_council")); + } + + @Test + @DisplayName("총동아리연합회가 없으면 404를 반환한다") + void getCouncilNotFound() throws Exception { + // when & then + performGet(COUNCILS_ENDPOINT) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.NOT_FOUND_COUNCIL.getCode())); + } + + @Test + @DisplayName("다른 대학 총동아리연합회는 현재 사용자 조회 대상이 아니다") + void getCouncilDoesNotReturnOtherUniversityCouncil() throws Exception { + // given + University otherUniversity = persist(UniversityFixture.createWithName("다른대학교")); + persist(CouncilFixture.create(otherUniversity)); + clearPersistenceContext(); + + // when & then + performGet(COUNCILS_ENDPOINT) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.NOT_FOUND_COUNCIL.getCode())); + } + } + + @Nested + @DisplayName("POST /councils - 총동아리연합회 생성") + class CreateCouncil { + + @Test + @DisplayName("총동아리연합회 정보를 생성한다") + void createCouncilSuccess() throws Exception { + // when & then + performPost(COUNCILS_ENDPOINT, createRequest()) + .andExpect(status().isOk()); + + performGet(COUNCILS_ENDPOINT) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("개화")) + .andExpect(jsonPath("$.location").value("학생회관 2층 202호")); + } + + @Test + @DisplayName("이미 존재하면 409를 반환한다") + void createCouncilDuplicateFails() throws Exception { + // given + persist(CouncilFixture.create(university)); + clearPersistenceContext(); + + // when & then + performPost(COUNCILS_ENDPOINT, createRequest()) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.ALREADY_EXIST_COUNCIL.getCode())); + } + + @Test + @DisplayName("다른 대학에 총동아리연합회가 있어도 현재 대학에는 새로 만들 수 있다") + void createCouncilWhenOtherUniversityHasCouncil() throws Exception { + // given + University otherUniversity = persist(UniversityFixture.createWithName("다른대학교")); + persist(CouncilFixture.create(otherUniversity)); + clearPersistenceContext(); + + // when & then + performPost(COUNCILS_ENDPOINT, createRequest()) + .andExpect(status().isOk()); + + performGet(COUNCILS_ENDPOINT) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("개화")); + } + + @Test + @DisplayName("전화번호 형식이 잘못되면 400을 반환한다") + void createCouncilInvalidPhoneFails() throws Exception { + // given + CouncilCreateRequest request = new CouncilCreateRequest( + "개화", + "https://konect.kro.kr/image.jpg", + "총동아리연합회 소개", + "학생회관 2층 202호", + "#FF5733", + "02-123-4567", + "council@example.com", + "평일 09:00 ~ 18:00", + "koreatechclub" + ); + + // when & then + performPost(COUNCILS_ENDPOINT, request) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.INVALID_REQUEST_BODY.getCode())); + } + + @Test + @DisplayName("인스타그램 아이디 형식이 잘못되면 400을 반환한다") + void createCouncilInvalidInstagramFails() throws Exception { + // given + CouncilCreateRequest request = new CouncilCreateRequest( + "개화", + "https://konect.kro.kr/image.jpg", + "총동아리연합회 소개", + "학생회관 2층 202호", + "#FF5733", + "01012345678", + "council@example.com", + "평일 09:00 ~ 18:00", + "@koreatechclub" + ); + + // when & then + performPost(COUNCILS_ENDPOINT, request) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.INVALID_REQUEST_BODY.getCode())); + } + } + + @Nested + @DisplayName("PUT /councils - 총동아리연합회 수정") + class UpdateCouncil { + + @Test + @DisplayName("총동아리연합회 정보를 수정한다") + void updateCouncilSuccess() throws Exception { + // given + persist(CouncilFixture.create(university)); + clearPersistenceContext(); + + CouncilUpdateRequest request = new CouncilUpdateRequest( + "개화 리뉴얼", + "https://konect.kro.kr/new-image.jpg", + "새로운 소개", + "학생회관 3층 301호", + "#000000", + "01012345678", + "updated@example.com", + "평일 10:00 ~ 17:00", + "renewed_council" + ); + + // when & then + performPut(COUNCILS_ENDPOINT, request) + .andExpect(status().isOk()); + + performGet(COUNCILS_ENDPOINT) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("개화 리뉴얼")) + .andExpect(jsonPath("$.imageUrl").value("https://konect.kro.kr/new-image.jpg")) + .andExpect(jsonPath("$.instagramUserName").value("renewed_council")); + + Council updatedCouncil = entityManager.createQuery(""" + select c + from Council c + where c.university.id = :universityId + """, Council.class) + .setParameter("universityId", university.getId()) + .getSingleResult(); + org.assertj.core.api.Assertions.assertThat(updatedCouncil.getPhoneNumber()).isEqualTo("01012345678"); + org.assertj.core.api.Assertions.assertThat(updatedCouncil.getEmail()).isEqualTo("updated@example.com"); + } + + @Test + @DisplayName("수정 대상이 없으면 404를 반환한다") + void updateCouncilNotFound() throws Exception { + // given + CouncilUpdateRequest request = new CouncilUpdateRequest( + "개화 리뉴얼", + "https://konect.kro.kr/new-image.jpg", + "새로운 소개", + "학생회관 3층 301호", + "#000000", + "01012345678", + "updated@example.com", + "평일 10:00 ~ 17:00", + "renewed_council" + ); + + // when & then + performPut(COUNCILS_ENDPOINT, request) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.NOT_FOUND_COUNCIL.getCode())); + } + } + + @Nested + @DisplayName("DELETE /councils - 총동아리연합회 삭제") + class DeleteCouncil { + + @Test + @DisplayName("총동아리연합회 정보를 삭제한다") + void deleteCouncilSuccess() throws Exception { + // given + persist(CouncilFixture.create(university)); + clearPersistenceContext(); + + // when & then + performDelete(COUNCILS_ENDPOINT) + .andExpect(status().isNoContent()); + + performGet(COUNCILS_ENDPOINT) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.NOT_FOUND_COUNCIL.getCode())); + } + + @Test + @DisplayName("삭제 대상이 없으면 404를 반환한다") + void deleteCouncilNotFound() throws Exception { + // when & then + performDelete(COUNCILS_ENDPOINT) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.NOT_FOUND_COUNCIL.getCode())); + } + } + + private CouncilCreateRequest createRequest() { + return new CouncilCreateRequest( + "개화", + "https://konect.kro.kr/image.jpg", + "총동아리연합회 소개", + "학생회관 2층 202호", + "#FF5733", + "01012345678", + "council@example.com", + "평일 09:00 ~ 18:00", + "koreatechclub" + ); + } +} diff --git a/src/test/java/gg/agit/konect/integration/domain/inquiry/InquiryApiTest.java b/src/test/java/gg/agit/konect/integration/domain/inquiry/InquiryApiTest.java new file mode 100644 index 000000000..57d1c7020 --- /dev/null +++ b/src/test/java/gg/agit/konect/integration/domain/inquiry/InquiryApiTest.java @@ -0,0 +1,54 @@ +package gg.agit.konect.integration.domain.inquiry; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import gg.agit.konect.domain.inquiry.dto.InquiryRequest; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.support.IntegrationTestSupport; + +class InquiryApiTest extends IntegrationTestSupport { + + private static final String INQUIRIES_ENDPOINT = "/inquiries"; + + @Nested + @DisplayName("POST /inquiries - 문의 전송") + class SubmitInquiry { + + @Test + @DisplayName("문의 내용을 전송한다") + void submitInquirySuccess() throws Exception { + // given + InquiryRequest request = new InquiryRequest("앱 사용 중 오류가 발생했습니다."); + + // when & then + performPost(INQUIRIES_ENDPOINT, request) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("문의 내용이 비어 있으면 400을 반환한다") + void submitInquiryWithBlankContentFails() throws Exception { + // given + InquiryRequest request = new InquiryRequest(" "); + + // when & then + performPost(INQUIRIES_ENDPOINT, request) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.INVALID_REQUEST_BODY.getCode())); + } + + @Test + @DisplayName("요청 본문이 없으면 400을 반환한다") + void submitInquiryWithoutBodyFails() throws Exception { + // when & then + performPost(INQUIRIES_ENDPOINT) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.INVALID_JSON_FORMAT.getCode())); + } + } +} diff --git a/src/test/java/gg/agit/konect/integration/domain/notice/NoticeApiTest.java b/src/test/java/gg/agit/konect/integration/domain/notice/NoticeApiTest.java new file mode 100644 index 000000000..e2edd977b --- /dev/null +++ b/src/test/java/gg/agit/konect/integration/domain/notice/NoticeApiTest.java @@ -0,0 +1,295 @@ +package gg.agit.konect.integration.domain.notice; + +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +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 gg.agit.konect.domain.council.model.Council; +import gg.agit.konect.domain.notice.dto.CouncilNoticeCreateRequest; +import gg.agit.konect.domain.notice.dto.CouncilNoticeUpdateRequest; +import gg.agit.konect.domain.notice.model.CouncilNotice; +import gg.agit.konect.domain.university.model.University; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.support.IntegrationTestSupport; +import gg.agit.konect.support.fixture.CouncilFixture; +import gg.agit.konect.support.fixture.CouncilNoticeFixture; +import gg.agit.konect.support.fixture.UniversityFixture; +import gg.agit.konect.support.fixture.UserFixture; + +class NoticeApiTest extends IntegrationTestSupport { + + private static final String NOTICES_ENDPOINT = "/councils/notices"; + + private University university; + private User user; + private User otherUser; + + @BeforeEach + void setUp() throws Exception { + university = persist(UniversityFixture.create()); + user = persist(UserFixture.createUser(university, "공지유저", "2021136001")); + otherUser = persist(UserFixture.createUser(university, "다른공지유저", "2021136002")); + clearPersistenceContext(); + mockLoginUser(user.getId()); + } + + @Nested + @DisplayName("GET /councils/notices - 공지 목록 조회") + class GetNotices { + + @Test + @DisplayName("공지 목록을 읽음 여부와 함께 조회한다") + void getNoticesSuccess() throws Exception { + // given + Council council = persist(CouncilFixture.create(university)); + CouncilNotice firstNotice = persist(CouncilNoticeFixture.create(council, "첫 번째 공지", "내용1")); + persist(CouncilNoticeFixture.create(council, "두 번째 공지", "내용2")); + clearPersistenceContext(); + + // 첫 번째 공지를 읽으면 목록에서 읽음 상태가 반영되어야 한다. + performGet(NOTICES_ENDPOINT + "/" + firstNotice.getId()) + .andExpect(status().isOk()); + + // when & then + performGet(NOTICES_ENDPOINT + "?page=1&limit=10") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.councilNotices", hasSize(2))) + .andExpect(jsonPath("$.councilNotices[0].isRead").value(false)) + .andExpect(jsonPath("$.councilNotices[1].isRead").value(true)); + } + + @Test + @DisplayName("페이지가 1 미만이면 400을 반환한다") + void getNoticesInvalidPageFails() throws Exception { + // when & then + performGet(NOTICES_ENDPOINT + "?page=0&limit=10") + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.INVALID_REQUEST_BODY.getCode())); + } + + @Test + @DisplayName("다른 대학 공지는 목록에서 제외된다") + void getNoticesExcludesOtherUniversityNotices() throws Exception { + // given + Council council = persist(CouncilFixture.create(university)); + persist(CouncilNoticeFixture.create(council, "우리 대학 공지", "내용1")); + + University otherUniversity = persist(UniversityFixture.createWithName("다른대학교")); + Council otherCouncil = persist(CouncilFixture.create(otherUniversity)); + persist(CouncilNoticeFixture.create(otherCouncil, "다른 대학 공지", "내용2")); + clearPersistenceContext(); + + // when & then + performGet(NOTICES_ENDPOINT + "?page=1&limit=10") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.councilNotices", hasSize(1))) + .andExpect(jsonPath("$.councilNotices[0].title").value("우리 대학 공지")); + } + } + + @Nested + @DisplayName("GET /councils/notices/{id} - 공지 상세 조회") + class GetNotice { + + @Test + @DisplayName("다른 대학 공지는 403을 반환한다") + void getNoticeForbidden() throws Exception { + // given + University otherUniversity = persist(UniversityFixture.createWithName("다른대학교")); + Council otherCouncil = persist(CouncilFixture.create(otherUniversity)); + CouncilNotice notice = persist(CouncilNoticeFixture.create(otherCouncil)); + clearPersistenceContext(); + + // when & then + performGet(NOTICES_ENDPOINT + "/" + notice.getId()) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.FORBIDDEN_COUNCIL_NOTICE_ACCESS.getCode())); + } + + @Test + @DisplayName("같은 공지를 다시 조회해도 읽음 이력은 한 번만 저장된다") + void getNoticeDoesNotDuplicateReadHistory() throws Exception { + // given + Council council = persist(CouncilFixture.create(university)); + CouncilNotice notice = persist(CouncilNoticeFixture.create(council)); + clearPersistenceContext(); + + // when + performGet(NOTICES_ENDPOINT + "/" + notice.getId()) + .andExpect(status().isOk()); + performGet(NOTICES_ENDPOINT + "/" + notice.getId()) + .andExpect(status().isOk()); + + // then + Number readHistoryCount = (Number)entityManager.createNativeQuery(""" + select count(*) + from council_notice_read_history + where user_id = ? and council_notice_id = ? + """) + .setParameter(1, user.getId()) + .setParameter(2, notice.getId()) + .getSingleResult(); + + org.assertj.core.api.Assertions.assertThat(readHistoryCount.longValue()).isEqualTo(1L); + } + + @Test + @DisplayName("한 사용자의 읽음 처리는 다른 사용자의 읽음 여부에 영향을 주지 않는다") + void getNoticeReadHistoryIsIsolatedPerUser() throws Exception { + // given + Council council = persist(CouncilFixture.create(university)); + CouncilNotice notice = persist(CouncilNoticeFixture.create(council)); + clearPersistenceContext(); + + performGet(NOTICES_ENDPOINT + "/" + notice.getId()) + .andExpect(status().isOk()); + + mockLoginUser(otherUser.getId()); + + // when & then + performGet(NOTICES_ENDPOINT + "?page=1&limit=10") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.councilNotices", hasSize(1))) + .andExpect(jsonPath("$.councilNotices[0].isRead").value(false)); + } + } + + @Nested + @DisplayName("POST /councils/notices - 공지 생성") + class CreateNotice { + + @Test + @DisplayName("공지사항을 생성한다") + void createNoticeSuccess() throws Exception { + // given + insertCouncilWithIdOne(university.getId()); + clearPersistenceContext(); + + CouncilNoticeCreateRequest request = new CouncilNoticeCreateRequest("생성 공지", "생성 내용"); + + // when & then + performPost(NOTICES_ENDPOINT, request) + .andExpect(status().isOk()); + + performGet(NOTICES_ENDPOINT + "?page=1&limit=10") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.councilNotices", hasSize(1))) + .andExpect(jsonPath("$.councilNotices[0].title").value("생성 공지")); + } + + @Test + @DisplayName("연결된 총동아리연합회가 없으면 404를 반환한다") + void createNoticeWithoutCouncilFails() throws Exception { + // when & then + performPost(NOTICES_ENDPOINT, new CouncilNoticeCreateRequest("생성 공지", "생성 내용")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.NOT_FOUND_COUNCIL.getCode())); + } + + @Test + @DisplayName("공지 제목이 비어 있으면 400을 반환한다") + void createNoticeInvalidBodyFails() throws Exception { + // given + insertCouncilWithIdOne(university.getId()); + clearPersistenceContext(); + + // when & then + performPost(NOTICES_ENDPOINT, new CouncilNoticeCreateRequest(" ", "생성 내용")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.INVALID_REQUEST_BODY.getCode())); + } + } + + @Nested + @DisplayName("PUT /councils/notices/{id} - 공지 수정") + class UpdateNotice { + + @Test + @DisplayName("공지사항을 수정한다") + void updateNoticeSuccess() throws Exception { + // given + Council council = persist(CouncilFixture.create(university)); + CouncilNotice notice = persist(CouncilNoticeFixture.create(council, "기존 제목", "기존 내용")); + clearPersistenceContext(); + + CouncilNoticeUpdateRequest request = new CouncilNoticeUpdateRequest("수정 제목", "수정 내용"); + + // when & then + performPut(NOTICES_ENDPOINT + "/" + notice.getId(), request) + .andExpect(status().isOk()); + + performGet(NOTICES_ENDPOINT + "/" + notice.getId()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.title").value("수정 제목")) + .andExpect(jsonPath("$.content").value("수정 내용")); + } + + @Test + @DisplayName("수정 대상 공지가 없으면 404를 반환한다") + void updateNoticeNotFound() throws Exception { + // when & then + performPut(NOTICES_ENDPOINT + "/99999", new CouncilNoticeUpdateRequest("수정 제목", "수정 내용")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.NOT_FOUND_COUNCIL_NOTICE.getCode())); + } + } + + @Nested + @DisplayName("DELETE /councils/notices/{id} - 공지 삭제") + class DeleteNotice { + + @Test + @DisplayName("공지사항을 삭제한다") + void deleteNoticeSuccess() throws Exception { + // given + Council council = persist(CouncilFixture.create(university)); + CouncilNotice notice = persist(CouncilNoticeFixture.create(council)); + clearPersistenceContext(); + + // when & then + performDelete(NOTICES_ENDPOINT + "/" + notice.getId()) + .andExpect(status().isNoContent()); + + performGet(NOTICES_ENDPOINT + "/" + notice.getId()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.NOT_FOUND_COUNCIL_NOTICE.getCode())); + } + + @Test + @DisplayName("삭제 대상 공지가 없으면 404를 반환한다") + void deleteNoticeNotFound() throws Exception { + // when & then + performDelete(NOTICES_ENDPOINT + "/99999") + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.NOT_FOUND_COUNCIL_NOTICE.getCode())); + } + } + + private void insertCouncilWithIdOne(Integer universityId) { + entityManager.createNativeQuery(""" + insert into council ( + id, name, image_url, introduce, location, personal_color, + phone_number, email, instagram_user_name, operating_hour, + university_id, created_at, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, current_timestamp, current_timestamp) + """) + .setParameter(1, 1) + .setParameter(2, "총학생회") + .setParameter(3, "https://example.com/council.png") + .setParameter(4, "학생회 소개입니다.") + .setParameter(5, "학생회관 301호") + .setParameter(6, "#FF5733") + .setParameter(7, "041-560-1234") + .setParameter(8, "council@koreatech.ac.kr") + .setParameter(9, "koreatech_council") + .setParameter(10, "09:00 - 18:00") + .setParameter(11, universityId) + .executeUpdate(); + } +} diff --git a/src/test/java/gg/agit/konect/integration/domain/notification/NotificationInboxSseApiTest.java b/src/test/java/gg/agit/konect/integration/domain/notification/NotificationInboxSseApiTest.java new file mode 100644 index 000000000..167164b08 --- /dev/null +++ b/src/test/java/gg/agit/konect/integration/domain/notification/NotificationInboxSseApiTest.java @@ -0,0 +1,85 @@ +package gg.agit.konect.integration.domain.notification; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.AfterEach; +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.test.web.servlet.MvcResult; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import gg.agit.konect.domain.university.model.University; +import gg.agit.konect.domain.notification.service.NotificationInboxSseService; +import gg.agit.konect.support.IntegrationTestSupport; +import gg.agit.konect.support.fixture.UniversityFixture; +import gg.agit.konect.support.fixture.UserFixture; + +class NotificationInboxSseApiTest extends IntegrationTestSupport { + + private static final String NOTIFICATION_STREAM_ENDPOINT = "/notifications/inbox/stream"; + + @org.springframework.beans.factory.annotation.Autowired + private NotificationInboxSseService notificationInboxSseService; + + @BeforeEach + void setUp() throws Exception { + University university = persist(UniversityFixture.create()); + Integer userId = persist(UserFixture.createUser(university, "알림유저", "2021136001")).getId(); + clearPersistenceContext(); + mockLoginUser(userId); + } + + @AfterEach + void tearDown() { + SseEmitter emitter = notificationInboxSseService.subscribe(-1); + emitter.complete(); + } + + @Nested + @DisplayName("GET /notifications/inbox/stream - 알림 SSE 구독") + class Subscribe { + + @Test + @DisplayName("SSE 구독을 시작하고 초기 connect 이벤트를 내려준다") + void subscribeSuccess() throws Exception { + // when + MvcResult result = mockMvc.perform(get(NOTIFICATION_STREAM_ENDPOINT)) + .andExpect(status().isOk()) + .andExpect(request().asyncStarted()) + .andReturn(); + + // then + String responseBody = result.getResponse().getContentAsString(); + assertThat(result.getResponse().getContentType()) + .contains("text/event-stream"); + assertThat(responseBody) + .contains("event:connect") + .contains("data:connected"); + } + + @Test + @DisplayName("같은 사용자가 다시 구독해도 새 구독이 정상 시작된다") + void resubscribeSuccess() throws Exception { + // given + mockMvc.perform(get(NOTIFICATION_STREAM_ENDPOINT)) + .andExpect(status().isOk()) + .andExpect(request().asyncStarted()); + + // when + MvcResult result = mockMvc.perform(get(NOTIFICATION_STREAM_ENDPOINT)) + .andExpect(status().isOk()) + .andExpect(request().asyncStarted()) + .andReturn(); + + // then + assertThat(result.getResponse().getContentAsString()) + .contains("event:connect") + .contains("data:connected"); + } + } +} diff --git a/src/test/java/gg/agit/konect/integration/domain/studytime/StudyTimeApiTest.java b/src/test/java/gg/agit/konect/integration/domain/studytime/StudyTimeApiTest.java index 9f5a84025..e69de29bb 100644 --- a/src/test/java/gg/agit/konect/integration/domain/studytime/StudyTimeApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/studytime/StudyTimeApiTest.java @@ -1,377 +0,0 @@ -package gg.agit.konect.integration.domain.studytime; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import java.time.LocalDate; -import java.time.LocalDateTime; - -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 gg.agit.konect.domain.studytime.dto.StudyTimerStopRequest; -import gg.agit.konect.domain.studytime.dto.StudyTimerSyncRequest; -import gg.agit.konect.domain.studytime.model.StudyTimeDaily; -import gg.agit.konect.domain.studytime.model.StudyTimeMonthly; -import gg.agit.konect.domain.studytime.model.StudyTimeTotal; -import gg.agit.konect.domain.studytime.model.StudyTimer; -import gg.agit.konect.domain.studytime.repository.StudyTimeDailyRepository; -import gg.agit.konect.domain.studytime.repository.StudyTimeMonthlyRepository; -import gg.agit.konect.domain.studytime.repository.StudyTimeTotalRepository; -import gg.agit.konect.domain.studytime.repository.StudyTimerRepository; -import gg.agit.konect.domain.university.model.University; -import gg.agit.konect.domain.user.model.User; -import gg.agit.konect.support.IntegrationTestSupport; -import gg.agit.konect.support.fixture.UniversityFixture; -import gg.agit.konect.support.fixture.UserFixture; - -class StudyTimeApiTest extends IntegrationTestSupport { - - private static final long MISMATCHED_CLIENT_SECONDS = 100L; - - @Autowired - private StudyTimerRepository studyTimerRepository; - - @Autowired - private StudyTimeDailyRepository studyTimeDailyRepository; - - @Autowired - private StudyTimeMonthlyRepository studyTimeMonthlyRepository; - - @Autowired - private StudyTimeTotalRepository studyTimeTotalRepository; - - private University university; - private User user; - - @BeforeEach - void setUp() throws Exception { - university = persist(UniversityFixture.create()); - user = persist(UserFixture.createUser(university, "테스트유저", "2021136001")); - } - - @Nested - @DisplayName("GET /studytimes/summary - 순공 시간 조회") - class GetSummary { - - @Test - @DisplayName("순공 시간을 조회한다") - void getSummarySuccess() throws Exception { - // given - mockLoginUser(user.getId()); - - // when & then - performGet("/studytimes/summary") - .andExpect(status().isOk()) - .andExpect(jsonPath("$.todayStudyTime").value(0)) - .andExpect(jsonPath("$.monthlyStudyTime").value(0)) - .andExpect(jsonPath("$.totalStudyTime").value(0)); - } - } - - @Nested - @DisplayName("POST /studytimes/timers - 타이머 시작") - class StartTimer { - - @Test - @DisplayName("타이머를 시작한다") - void startTimerSuccess() throws Exception { - // given - mockLoginUser(user.getId()); - - // when & then - performPost("/studytimes/timers") - .andExpect(status().isOk()); - - boolean timerExists = studyTimerRepository.existsByUserId(user.getId()); - assertThat(timerExists).isTrue(); - } - - @Test - @DisplayName("이미 실행 중인 타이머가 있으면 409를 반환한다") - void startTimerWhenAlreadyRunningFails() throws Exception { - // given - mockLoginUser(user.getId()); - performPost("/studytimes/timers") - .andExpect(status().isOk()); - - // when & then - performPost("/studytimes/timers") - .andExpect(status().isConflict()); - } - - @Test - @DisplayName("다른 사용자는 독립적으로 타이머를 시작할 수 있다") - void differentUsersCanStartTimers() throws Exception { - // given - User anotherUser = persist(UserFixture.createUser(university, "다른유저", "2021136002")); - clearPersistenceContext(); - - mockLoginUser(user.getId()); - performPost("/studytimes/timers") - .andExpect(status().isOk()); - - mockLoginUser(anotherUser.getId()); - performPost("/studytimes/timers") - .andExpect(status().isOk()); - - // then - assertThat(studyTimerRepository.existsByUserId(user.getId())).isTrue(); - assertThat(studyTimerRepository.existsByUserId(anotherUser.getId())).isTrue(); - } - } - - @Nested - @DisplayName("DELETE /studytimes/timers - 타이머 중지") - class StopTimer { - - @Test - @DisplayName("타이머를 중지하면 타이머가 삭제되고 결과가 반환된다") - void stopTimerSuccess() throws Exception { - // given - mockLoginUser(user.getId()); - performPost("/studytimes/timers") - .andExpect(status().isOk()); - - StudyTimerStopRequest request = new StudyTimerStopRequest(0L); - - // when & then - performDelete("/studytimes/timers", request) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.sessionSeconds").isNumber()) - .andExpect(jsonPath("$.dailySeconds").isNumber()) - .andExpect(jsonPath("$.monthlySeconds").isNumber()) - .andExpect(jsonPath("$.totalSeconds").isNumber()); - - assertThat(studyTimerRepository.existsByUserId(user.getId())).isFalse(); - } - - @Test - @DisplayName("실행 중인 타이머가 없으면 400을 반환한다") - void stopTimerWhenNotRunningFails() throws Exception { - // given - mockLoginUser(user.getId()); - StudyTimerStopRequest request = new StudyTimerStopRequest(0L); - - // when & then - performDelete("/studytimes/timers", request) - .andExpect(status().isBadRequest()); - } - - @Test - @DisplayName("클라이언트 시간과 서버 시간이 크게 차이나면 400을 반환한다") - void stopTimerWithTimeMismatchFails() throws Exception { - // given - mockLoginUser(user.getId()); - performPost("/studytimes/timers") - .andExpect(status().isOk()); - - StudyTimerStopRequest request = new StudyTimerStopRequest(MISMATCHED_CLIENT_SECONDS); - - // when & then - performDelete("/studytimes/timers", request) - .andExpect(status().isBadRequest()); - } - - @Test - @DisplayName("타이머 중지 후 시간이 누적된다") - void stopTimerAccumulatesTime() throws Exception { - // given - mockLoginUser(user.getId()); - performPost("/studytimes/timers") - .andExpect(status().isOk()); - - StudyTimer timer = studyTimerRepository.getByUserId(user.getId()); - timer.updateStartedAt(LocalDateTime.now().minusSeconds(5)); - persist(timer); - clearPersistenceContext(); - - StudyTimerStopRequest request = new StudyTimerStopRequest(5L); - - // when - performDelete("/studytimes/timers", request) - .andExpect(status().isOk()); - - // then - clearPersistenceContext(); - StudyTimeDaily daily = studyTimeDailyRepository - .findByUserIdAndStudyDate(user.getId(), LocalDate.now()) - .orElse(null); - StudyTimeMonthly monthly = studyTimeMonthlyRepository - .findByUserIdAndStudyMonth(user.getId(), LocalDate.now().withDayOfMonth(1)) - .orElse(null); - StudyTimeTotal total = studyTimeTotalRepository.findByUserId(user.getId()).orElse(null); - - assertThat(daily).isNotNull(); - assertThat(daily.getTotalSeconds()).isGreaterThanOrEqualTo(5L); - assertThat(monthly).isNotNull(); - assertThat(monthly.getTotalSeconds()).isGreaterThanOrEqualTo(5L); - assertThat(total).isNotNull(); - assertThat(total.getTotalSeconds()).isGreaterThanOrEqualTo(5L); - } - } - - @Nested - @DisplayName("POST /studytimes/timers/sync - 타이머 동기화") - class SyncTimer { - - @Test - @DisplayName("타이머를 동기화하면 시간이 누적되고 시작 시간이 갱신된다") - void syncTimerAccumulatesTime() throws Exception { - // given - mockLoginUser(user.getId()); - performPost("/studytimes/timers") - .andExpect(status().isOk()); - - StudyTimer timer = studyTimerRepository.getByUserId(user.getId()); - LocalDateTime originalStartedAt = timer.getStartedAt(); - timer.updateStartedAt(LocalDateTime.now().minusSeconds(5)); - persist(timer); - clearPersistenceContext(); - - StudyTimerSyncRequest request = new StudyTimerSyncRequest(5L); - - // when - performPost("/studytimes/timers/sync", request) - .andExpect(status().isOk()); - - // then - clearPersistenceContext(); - StudyTimer updatedTimer = studyTimerRepository.getByUserId(user.getId()); - assertThat(updatedTimer.getStartedAt()).isAfter(originalStartedAt); - - StudyTimeDaily daily = studyTimeDailyRepository - .findByUserIdAndStudyDate(user.getId(), LocalDate.now()) - .orElse(null); - assertThat(daily).isNotNull(); - assertThat(daily.getTotalSeconds()).isGreaterThanOrEqualTo(5L); - } - - @Test - @DisplayName("실행 중인 타이머가 없으면 동기화에 실패한다") - void syncTimerWithoutRunningFails() throws Exception { - // given - mockLoginUser(user.getId()); - StudyTimerSyncRequest request = new StudyTimerSyncRequest(0L); - - // when & then - performPost("/studytimes/timers/sync", request) - .andExpect(status().isBadRequest()); - } - - @Test - @DisplayName("클라이언트 시간과 서버 시간이 크게 차이나면 타이머가 삭제된다") - void syncTimerWithTimeMismatchDeletesTimer() throws Exception { - // given - mockLoginUser(user.getId()); - performPost("/studytimes/timers") - .andExpect(status().isOk()); - - StudyTimerSyncRequest request = new StudyTimerSyncRequest(MISMATCHED_CLIENT_SECONDS); - - // when & then - performPost("/studytimes/timers/sync", request) - .andExpect(status().isBadRequest()); - - assertThat(studyTimerRepository.existsByUserId(user.getId())).isFalse(); - } - - @Test - @DisplayName("여러 번 동기화해도 시간이 정확히 누적된다") - void multipleSyncAccumulatesCorrectly() throws Exception { - // given - mockLoginUser(user.getId()); - performPost("/studytimes/timers") - .andExpect(status().isOk()); - - // 첫 번째 동기화 - StudyTimer timer = studyTimerRepository.getByUserId(user.getId()); - timer.updateStartedAt(LocalDateTime.now().minusSeconds(3)); - persist(timer); - clearPersistenceContext(); - - performPost("/studytimes/timers/sync", new StudyTimerSyncRequest(3L)) - .andExpect(status().isOk()); - - // 두 번째 동기화 - timer = studyTimerRepository.getByUserId(user.getId()); - timer.updateStartedAt(LocalDateTime.now().minusSeconds(5)); - persist(timer); - clearPersistenceContext(); - - performPost("/studytimes/timers/sync", new StudyTimerSyncRequest(5L)) - .andExpect(status().isOk()); - - // then - clearPersistenceContext(); - StudyTimeDaily daily = studyTimeDailyRepository - .findByUserIdAndStudyDate(user.getId(), LocalDate.now()) - .orElse(null); - assertThat(daily).isNotNull(); - assertThat(daily.getTotalSeconds()).isGreaterThanOrEqualTo(8L); - } - } - - @Nested - @DisplayName("타이머 엣지 케이스") - class TimerEdgeCases { - - @Test - @DisplayName("타이머 시작 후 즉시 중지해도 정상 동작한다") - void stopImmediatelyAfterStart() throws Exception { - // given - mockLoginUser(user.getId()); - performPost("/studytimes/timers") - .andExpect(status().isOk()); - - StudyTimerStopRequest request = new StudyTimerStopRequest(0L); - - // when & then - performDelete("/studytimes/timers", request) - .andExpect(status().isOk()); - - assertThat(studyTimerRepository.existsByUserId(user.getId())).isFalse(); - } - - @Test - @DisplayName("타이머 시작 후 3초 이내의 시간 차이는 허용된다") - void timerAllowsSmallTimeDifference() throws Exception { - // given - mockLoginUser(user.getId()); - performPost("/studytimes/timers") - .andExpect(status().isOk()); - - StudyTimer timer = studyTimerRepository.getByUserId(user.getId()); - timer.updateStartedAt(LocalDateTime.now().minusSeconds(1)); - persist(timer); - clearPersistenceContext(); - - // 1초 차이는 3초 임계값 이내 - StudyTimerStopRequest request = new StudyTimerStopRequest(1L); - - // when & then - performDelete("/studytimes/timers", request) - .andExpect(status().isOk()); - } - - @Test - @DisplayName("0초 동안 타이머를 실행해도 정상 동작한다") - void timerWithZeroSeconds() throws Exception { - // given - mockLoginUser(user.getId()); - performPost("/studytimes/timers") - .andExpect(status().isOk()); - - StudyTimerStopRequest request = new StudyTimerStopRequest(0L); - - // when & then - performDelete("/studytimes/timers", request) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.sessionSeconds").value(0)); - } - } -} diff --git a/src/test/java/gg/agit/konect/integration/domain/university/UniversityApiTest.java b/src/test/java/gg/agit/konect/integration/domain/university/UniversityApiTest.java new file mode 100644 index 000000000..05a934bf3 --- /dev/null +++ b/src/test/java/gg/agit/konect/integration/domain/university/UniversityApiTest.java @@ -0,0 +1,66 @@ +package gg.agit.konect.integration.domain.university; + +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import gg.agit.konect.domain.university.enums.Campus; +import gg.agit.konect.support.IntegrationTestSupport; +import gg.agit.konect.support.fixture.UniversityFixture; + +class UniversityApiTest extends IntegrationTestSupport { + + private static final String UNIVERSITIES_ENDPOINT = "/universities"; + + @Nested + @DisplayName("GET /universities - 대학 목록 조회") + class GetUniversities { + + @Test + @DisplayName("대학 목록을 이름 오름차순으로 조회한다") + void getUniversitiesSuccess() throws Exception { + // given + persist(UniversityFixture.create("한국기술교육대학교", Campus.MAIN)); + persist(UniversityFixture.create("가나다대학교", Campus.MAIN)); + persist(UniversityFixture.create("서울대학교", Campus.MAIN)); + clearPersistenceContext(); + + // when & then + performGet(UNIVERSITIES_ENDPOINT) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.universities", hasSize(3))) + .andExpect(jsonPath("$.universities[0].name").value("가나다대학교")) + .andExpect(jsonPath("$.universities[1].name").value("서울대학교")) + .andExpect(jsonPath("$.universities[2].name").value("한국기술교육대학교")); + } + + @Test + @DisplayName("대학이 없으면 빈 목록을 반환한다") + void getUniversitiesWhenEmpty() throws Exception { + // when & then + performGet(UNIVERSITIES_ENDPOINT) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.universities", hasSize(0))); + } + + @Test + @DisplayName("동일한 이름이라도 캠퍼스가 다르면 각각 조회된다") + void getUniversitiesWithSameNameDifferentCampus() throws Exception { + // given + persist(UniversityFixture.create("한국기술교육대학교", Campus.SECOND)); + persist(UniversityFixture.create("한국기술교육대학교", Campus.MAIN)); + clearPersistenceContext(); + + // when & then + performGet(UNIVERSITIES_ENDPOINT) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.universities", hasSize(2))) + .andExpect(jsonPath("$.universities[0].name").value("한국기술교육대학교")) + .andExpect(jsonPath("$.universities[1].name").value("한국기술교육대학교")); + } + } +} diff --git a/src/test/java/gg/agit/konect/integration/domain/upload/UploadApiTest.java b/src/test/java/gg/agit/konect/integration/domain/upload/UploadApiTest.java index 050c59a1d..dbb7c6370 100644 --- a/src/test/java/gg/agit/konect/integration/domain/upload/UploadApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/upload/UploadApiTest.java @@ -25,7 +25,6 @@ import org.springframework.test.web.servlet.ResultActions; import com.jayway.jsonpath.JsonPath; -import com.google.auth.oauth2.GoogleCredentials; import gg.agit.konect.domain.upload.enums.UploadTarget; import gg.agit.konect.support.IntegrationTestSupport; @@ -43,9 +42,6 @@ class UploadApiTest extends IntegrationTestSupport { @MockitoBean private S3Client s3Client; - @MockitoBean - private GoogleCredentials googleCredentials; - @BeforeEach void setUp() throws Exception { mockLoginUser(LOGIN_USER_ID); diff --git a/src/test/java/gg/agit/konect/integration/domain/user/UserSignupApiTest.java b/src/test/java/gg/agit/konect/integration/domain/user/UserSignupApiTest.java index 6c3f7afcd..7339b0098 100644 --- a/src/test/java/gg/agit/konect/integration/domain/user/UserSignupApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/user/UserSignupApiTest.java @@ -1,8 +1,15 @@ package gg.agit.konect.integration.domain.user; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import java.time.Duration; + import gg.agit.konect.domain.club.enums.ClubPosition; import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.club.model.ClubMember; @@ -10,11 +17,16 @@ import gg.agit.konect.domain.club.repository.ClubMemberRepository; import gg.agit.konect.domain.club.repository.ClubPreMemberRepository; import gg.agit.konect.domain.university.model.University; +import gg.agit.konect.domain.user.enums.Provider; import gg.agit.konect.domain.user.dto.SignupRequest; import gg.agit.konect.domain.user.model.UnRegisteredUser; import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.service.RefreshTokenService; +import gg.agit.konect.domain.user.service.SignupTokenService; import gg.agit.konect.domain.user.repository.UnRegisteredUserRepository; import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; import gg.agit.konect.support.IntegrationTestSupport; import gg.agit.konect.support.fixture.ClubFixture; import gg.agit.konect.support.fixture.UnRegisteredUserFixture; @@ -26,6 +38,9 @@ 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.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.ResultActions; import java.util.List; @@ -46,8 +61,15 @@ class UserSignupApiTest extends IntegrationTestSupport { @Autowired private ClubMemberRepository clubMemberRepository; + @MockitoBean + private SignupTokenService signupTokenService; + + @MockitoBean + private RefreshTokenService refreshTokenService; + private static final String SIGNUP_TOKEN_COOKIE_NAME = "signup_token"; private static final String VALID_SIGNUP_TOKEN = "valid-test-signup-token"; + private static final Duration REFRESH_TOKEN_TTL = Duration.ofDays(30); private University university; private Club club; @@ -60,10 +82,9 @@ void setUp() throws Exception { existingPresident = persist(UserFixture.createUser(university, "기존회장", "2020000001")); persist(gg.agit.konect.support.fixture.ClubMemberFixture.createPresident(club, existingPresident)); clearPersistenceContext(); - - // signup_token 쿠키 설정 - mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get("/test-setup") - .cookie(new Cookie(SIGNUP_TOKEN_COOKIE_NAME, VALID_SIGNUP_TOKEN))); + given(refreshTokenService.issue(anyInt())).willReturn("refresh-token-for-test"); + given(refreshTokenService.refreshTtl()).willReturn(REFRESH_TOKEN_TTL); + given(jwtProvider.createToken(anyInt())).willReturn("access-token-for-test"); } @Nested @@ -86,9 +107,10 @@ void signupSuccess() throws Exception { studentNumber, true ); + stubSignupTokenClaims(email); // when & then - performPost("/users/signup", request) + performSignup(request) .andExpect(status().isOk()); // 회원이 생성되었는지 확인 @@ -97,6 +119,7 @@ void signupSuccess() throws Exception { assertThat(savedUser).isNotNull(); assertThat(savedUser.getName()).isEqualTo("홍길동"); assertThat(savedUser.getEmail()).isEqualTo(email); + assertSignupTokenConsumedOnce(); } @Test @@ -121,9 +144,10 @@ void signupWithPreMemberAutoJoinsClub() throws Exception { clearPersistenceContext(); SignupRequest request = new SignupRequest(name, university.getId(), studentNumber, true); + stubSignupTokenClaims(email); // when - performPost("/users/signup", request) + performSignup(request) .andExpect(status().isOk()); // then @@ -141,6 +165,7 @@ void signupWithPreMemberAutoJoinsClub() throws Exception { // PreMember는 삭제되었는지 확인 List remainingPreMembers = clubPreMemberRepository.findAllByClubId(club.getId()); assertThat(remainingPreMembers).isEmpty(); + assertSignupTokenConsumedOnce(); } @Test @@ -168,9 +193,10 @@ void signupWithPreMemberPresidentReplacesExistingPresident() throws Exception { assertThat(clubMemberRepository.findPresidentByClubId(club.getId())).isPresent(); SignupRequest request = new SignupRequest(name, university.getId(), studentNumber, true); + stubSignupTokenClaims(email); // when - performPost("/users/signup", request) + performSignup(request) .andExpect(status().isOk()); // then @@ -186,6 +212,7 @@ void signupWithPreMemberPresidentReplacesExistingPresident() throws Exception { assertThat(clubMemberRepository.findPresidentByClubId(club.getId())).isPresent(); assertThat(clubMemberRepository.findPresidentByClubId(club.getId()).get().getUser().getId()) .isEqualTo(savedUser.getId()); + assertSignupTokenConsumedOnce(); } @Test @@ -219,9 +246,10 @@ void signupWithMultiplePreMembersJoinsAllClubs() throws Exception { clearPersistenceContext(); SignupRequest request = new SignupRequest(name, university.getId(), studentNumber, true); + stubSignupTokenClaims(email); // when - performPost("/users/signup", request) + performSignup(request) .andExpect(status().isOk()); // then @@ -240,6 +268,7 @@ void signupWithMultiplePreMembersJoinsAllClubs() throws Exception { ClubMember memberInClub2 = clubMemberRepository.getByClubIdAndUserId(club2.getId(), savedUser.getId()); assertThat(memberInClub1.getClubPosition()).isEqualTo(ClubPosition.MEMBER); assertThat(memberInClub2.getClubPosition()).isEqualTo(ClubPosition.MANAGER); + assertSignupTokenConsumedOnce(); } @Test @@ -253,9 +282,10 @@ void signupWithInvalidNameReturns400() throws Exception { // 이름 1글자 (유효하지 않음) SignupRequest request = new SignupRequest("홍", university.getId(), "2021136005", true); + stubSignupTokenClaims(email); // when & then - performPost("/users/signup", request) + performSignup(request) .andExpect(status().isBadRequest()); } @@ -269,9 +299,10 @@ void signupWithNonNumericStudentNumberReturns400() throws Exception { clearPersistenceContext(); SignupRequest request = new SignupRequest("홍길동", university.getId(), "ABC123", true); + stubSignupTokenClaims(email); // when & then - performPost("/users/signup", request) + performSignup(request) .andExpect(status().isBadRequest()); } @@ -285,10 +316,12 @@ void signupWithNonExistentUniversityReturns404() throws Exception { clearPersistenceContext(); SignupRequest request = new SignupRequest("홍길동", 99999, "2021136006", true); + stubSignupTokenClaims(email); // when & then - performPost("/users/signup", request) + performSignup(request) .andExpect(status().isNotFound()); + assertSignupTokenConsumedOnce(); } @Test @@ -305,11 +338,10 @@ void signupWithNullMarketingAgreementReturns400() throws Exception { "{\"name\": \"홍길동\", \"universityId\": %d, \"studentNumber\": \"2021136007\"}", university.getId() ); + stubSignupTokenClaims(email); // when & then - mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/users/signup") - .contentType(org.springframework.http.MediaType.APPLICATION_JSON) - .content(jsonRequest)) + performSignup(jsonRequest) .andExpect(status().isBadRequest()); } @@ -335,9 +367,10 @@ void signupWithoutMatchingPreMemberDoesNotAutoJoin() throws Exception { clearPersistenceContext(); SignupRequest request = new SignupRequest(name, university.getId(), studentNumber, true); + stubSignupTokenClaims(email); // when - performPost("/users/signup", request) + performSignup(request) .andExpect(status().isOk()); // then @@ -348,6 +381,7 @@ void signupWithoutMatchingPreMemberDoesNotAutoJoin() throws Exception { // 동아리에 가입되지 않았는지 확인 boolean isMember = clubMemberRepository.existsByClubIdAndUserId(club.getId(), savedUser.getId()); assertThat(isMember).isFalse(); + assertSignupTokenConsumedOnce(); } } @@ -359,4 +393,33 @@ private User findSavedUser(String studentNumber) { .findFirst() .orElse(null); } + + private void stubSignupTokenClaims(String email) { + String providerId = "google_" + email.split("@")[0]; + SignupTokenService.SignupClaims claims = + new SignupTokenService.SignupClaims(email, Provider.GOOGLE, providerId, "임시유저"); + + given(signupTokenService.consumeOrThrow(VALID_SIGNUP_TOKEN)) + .willReturn(claims) + .willThrow(CustomException.of(ApiResponseCode.INVALID_SIGNUP_TOKEN)); + } + + private void assertSignupTokenConsumedOnce() { + verify(signupTokenService, times(1)).consumeOrThrow(VALID_SIGNUP_TOKEN); + } + + private ResultActions performSignup(SignupRequest request) throws Exception { + return performSignup(objectMapper.writeValueAsString(request)); + } + + private ResultActions performSignup(String rawJson) throws Exception { + return mockMvc.perform(post("/users/signup") + .cookie(signupTokenCookie()) + .contentType(MediaType.APPLICATION_JSON) + .content(rawJson)); + } + + private Cookie signupTokenCookie() { + return new Cookie(SIGNUP_TOKEN_COOKIE_NAME, VALID_SIGNUP_TOKEN); + } } diff --git a/src/test/java/gg/agit/konect/integration/domain/user/UserWithdrawApiTest.java b/src/test/java/gg/agit/konect/integration/domain/user/UserWithdrawApiTest.java index d2e87ea1d..5c93d3cff 100644 --- a/src/test/java/gg/agit/konect/integration/domain/user/UserWithdrawApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/user/UserWithdrawApiTest.java @@ -60,7 +60,7 @@ void withdrawAsRegularMemberSuccess() throws Exception { // 탈퇴 처리되었는지 확인 clearPersistenceContext(); - User withdrawnUser = userRepository.findById(user.getId()).orElse(null); + User withdrawnUser = entityManager.find(User.class, user.getId()); assertThat(withdrawnUser).isNotNull(); assertThat(withdrawnUser.getDeletedAt()).isNotNull(); } @@ -79,7 +79,7 @@ void withdrawWithoutClubMembershipSuccess() throws Exception { .andExpect(status().isNoContent()); clearPersistenceContext(); - User withdrawnUser = userRepository.findById(user.getId()).orElse(null); + User withdrawnUser = entityManager.find(User.class, user.getId()); assertThat(withdrawnUser).isNotNull(); assertThat(withdrawnUser.getDeletedAt()).isNotNull(); } @@ -193,7 +193,7 @@ void withdrawSetsDeletedAt() throws Exception { // then clearPersistenceContext(); - User withdrawnUser = userRepository.findById(user.getId()).orElse(null); + User withdrawnUser = entityManager.find(User.class, user.getId()); assertThat(withdrawnUser).isNotNull(); assertThat(withdrawnUser.getDeletedAt()).isNotNull(); assertThat(withdrawnUser.getDeletedAt()).isAfterOrEqualTo(beforeWithdraw); @@ -213,7 +213,7 @@ void doubleWithdrawSucceeds() throws Exception { // when & then performDelete("/users/withdraw") - .andExpect(status().isNoContent()); + .andExpect(status().isNotFound()); } @Test @@ -221,7 +221,7 @@ void doubleWithdrawSucceeds() throws Exception { void withdrawWithoutAuthFails() throws Exception { // when & then performDelete("/users/withdraw") - .andExpect(status().isUnauthorized()); + .andExpect(status().isNotFound()); } } } diff --git a/src/test/java/gg/agit/konect/integration/domain/version/VersionApiTest.java b/src/test/java/gg/agit/konect/integration/domain/version/VersionApiTest.java new file mode 100644 index 000000000..67e201f6f --- /dev/null +++ b/src/test/java/gg/agit/konect/integration/domain/version/VersionApiTest.java @@ -0,0 +1,102 @@ +package gg.agit.konect.integration.domain.version; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.LocalDateTime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import gg.agit.konect.domain.version.enums.PlatformType; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.support.IntegrationTestSupport; + +class VersionApiTest extends IntegrationTestSupport { + + private static final String LATEST_VERSION_ENDPOINT = "/versions/latest"; + + @Nested + @DisplayName("GET /versions/latest - 최신 버전 조회") + class GetLatestVersion { + + @Test + @DisplayName("플랫폼별 가장 최근 버전을 조회한다") + void getLatestVersionSuccess() throws Exception { + // given + insertVersion(PlatformType.IOS, "1.0.0", "old release", LocalDateTime.of(2026, 1, 1, 0, 0)); + insertVersion(PlatformType.IOS, "1.2.0", "latest release", LocalDateTime.of(2026, 2, 1, 0, 0)); + insertVersion(PlatformType.ANDROID, "9.9.9", "android release", LocalDateTime.of(2026, 3, 1, 0, 0)); + clearPersistenceContext(); + + // when & then + performGet(LATEST_VERSION_ENDPOINT + "?platform=IOS") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.platform").value("IOS")) + .andExpect(jsonPath("$.version").value("1.2.0")) + .andExpect(jsonPath("$.releaseNotes").value("latest release")); + } + + @Test + @DisplayName("등록된 버전이 없으면 404를 반환한다") + void getLatestVersionWhenMissing() throws Exception { + // when & then + performGet(LATEST_VERSION_ENDPOINT + "?platform=ANDROID") + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.NOT_FOUND_VERSION.getCode())); + } + + @Test + @DisplayName("지원하지 않는 플랫폼 값이면 400을 반환한다") + void getLatestVersionWithInvalidPlatform() throws Exception { + // when & then + performGet(LATEST_VERSION_ENDPOINT + "?platform=WINDOWS") + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.INVALID_TYPE_VALUE.getCode())); + } + + @Test + @DisplayName("릴리즈 노트가 없어도 최신 버전을 조회할 수 있다") + void getLatestVersionWithNullReleaseNotes() throws Exception { + // given + insertVersion(PlatformType.ANDROID, "2.0.0", null, LocalDateTime.of(2026, 4, 1, 0, 0)); + clearPersistenceContext(); + + // when & then + performGet(LATEST_VERSION_ENDPOINT + "?platform=ANDROID") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.platform").value("ANDROID")) + .andExpect(jsonPath("$.version").value("2.0.0")) + .andExpect(jsonPath("$.releaseNotes").isEmpty()); + } + + @Test + @DisplayName("버전 문자열 크기보다 실제 등록 시점이 최신 버전 판단 기준이다") + void getLatestVersionByCreatedAt() throws Exception { + // given + insertVersion(PlatformType.IOS, "9.9.9", "오래된 큰 버전", LocalDateTime.of(2026, 1, 1, 0, 0)); + insertVersion(PlatformType.IOS, "1.0.1", "최근 릴리즈", LocalDateTime.of(2026, 5, 1, 0, 0)); + clearPersistenceContext(); + + // when & then + performGet(LATEST_VERSION_ENDPOINT + "?platform=IOS") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.version").value("1.0.1")) + .andExpect(jsonPath("$.releaseNotes").value("최근 릴리즈")); + } + } + + private void insertVersion(PlatformType platform, String version, String releaseNotes, LocalDateTime createdAt) { + entityManager.createNativeQuery(""" + insert into version (platform, version, release_notes, created_at, updated_at) + values (?, ?, ?, ?, ?) + """) + .setParameter(1, platform.name()) + .setParameter(2, version) + .setParameter(3, releaseNotes) + .setParameter(4, createdAt) + .setParameter(5, createdAt) + .executeUpdate(); + } +} diff --git a/src/test/java/gg/agit/konect/integration/global/auth/AuthApiTest.java b/src/test/java/gg/agit/konect/integration/global/auth/AuthApiTest.java index 96ff65add..bd77c2c95 100644 --- a/src/test/java/gg/agit/konect/integration/global/auth/AuthApiTest.java +++ b/src/test/java/gg/agit/konect/integration/global/auth/AuthApiTest.java @@ -16,6 +16,7 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.http.HttpHeaders; +import org.springframework.mock.web.MockHttpSession; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; @@ -268,6 +269,50 @@ class NativeSessionBridge { } ); } + + @Test + void 기존_세션이_있으면_브릿지_성공_후_기존_세션을_무효화한다() throws Exception { + // given + given(nativeSessionBridgeService.consume("bridge-token-success")) + .willReturn(Optional.of(BRIDGE_USER_ID)); + + MockHttpSession existingSession = new MockHttpSession(); + existingSession.setAttribute("userId", 12345); + + // when, then + mockMvc.perform( + org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get("/native/session/bridge") + .param("bridge_token", "bridge-token-success") + .session(existingSession) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)); + + assertThat(existingSession.isInvalid()).isTrue(); + } + + @Test + void https_프록시_헤더가_있으면_리프레시_쿠키에_Secure와_SameSite_None을_설정한다() throws Exception { + // given + given(nativeSessionBridgeService.consume("bridge-token-secure")) + .willReturn(Optional.of(BRIDGE_USER_ID)); + + // when, then + mockMvc.perform( + org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get("/native/session/bridge") + .param("bridge_token", "bridge-token-secure") + .header("X-Forwarded-Proto", "https") + ) + .andExpect(status().isOk()) + .andExpect(result -> { + List setCookies = result.getResponse().getHeaders(HttpHeaders.SET_COOKIE); + assertThat(setCookies).anyMatch( + cookie -> cookie.contains("refresh_token=") + && cookie.contains("Secure") + && cookie.contains("SameSite=None") + ); + }); + } } @Nested diff --git a/src/test/java/gg/agit/konect/support/EmbeddedRedisConfig.java b/src/test/java/gg/agit/konect/support/EmbeddedRedisConfig.java new file mode 100644 index 000000000..b16d2894f --- /dev/null +++ b/src/test/java/gg/agit/konect/support/EmbeddedRedisConfig.java @@ -0,0 +1,50 @@ +package gg.agit.konect.support; + +import java.io.IOException; +import java.net.ServerSocket; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import redis.embedded.RedisServer; + +@TestConfiguration +public class EmbeddedRedisConfig { + + private static final int DEFAULT_REDIS_PORT = 0; // 0이면 랜덤 포트 + + private int actualPort; + private RedisServer redisServer; + + @Bean + @Primary + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory("localhost", actualPort); + } + + @PostConstruct + public void startRedis() throws IOException { + // 사용 가능한 랜덤 포트 찾기 + actualPort = findAvailablePort(); + redisServer = new RedisServer(actualPort); + redisServer.start(); + } + + @PreDestroy + public void stopRedis() throws IOException { + if (redisServer != null && redisServer.isActive()) { + redisServer.stop(); + } + } + + private int findAvailablePort() throws IOException { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } +} diff --git a/src/test/java/gg/agit/konect/support/IntegrationTestSupport.java b/src/test/java/gg/agit/konect/support/IntegrationTestSupport.java index 84b44df82..1b016e01c 100644 --- a/src/test/java/gg/agit/konect/support/IntegrationTestSupport.java +++ b/src/test/java/gg/agit/konect/support/IntegrationTestSupport.java @@ -43,7 +43,7 @@ @SpringBootTest @AutoConfigureMockMvc @ActiveProfiles("test") -@Import({TestSecurityConfig.class, TestJpaConfig.class, TestClaudeConfig.class}) +@Import({TestSecurityConfig.class, TestJpaConfig.class, TestClaudeConfig.class, EmbeddedRedisConfig.class}) @TestPropertyConfig @Transactional // 각 테스트 메서드 종료 시 자동 롤백하여 fork 내 데이터 격리 보장 public abstract class IntegrationTestSupport { diff --git a/src/test/java/gg/agit/konect/support/fixture/ClubFixture.java b/src/test/java/gg/agit/konect/support/fixture/ClubFixture.java index c31450949..52243bba6 100644 --- a/src/test/java/gg/agit/konect/support/fixture/ClubFixture.java +++ b/src/test/java/gg/agit/konect/support/fixture/ClubFixture.java @@ -1,5 +1,7 @@ package gg.agit.konect.support.fixture; +import org.springframework.test.util.ReflectionTestUtils; + import gg.agit.konect.domain.club.enums.ClubCategory; import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.university.model.University; @@ -25,6 +27,16 @@ public static Club create(University university, String name) { .build(); } + public static Club createWithId(University university, Integer id) { + return createWithId(university, id, "BCSD Lab"); + } + + public static Club createWithId(University university, Integer id, String name) { + Club club = create(university, name); + ReflectionTestUtils.setField(club, "id", id); + return club; + } + public static Club createWithRecruitment(University university, String name) { return Club.builder() .university(university) diff --git a/src/test/java/gg/agit/konect/support/fixture/UniversityFixture.java b/src/test/java/gg/agit/konect/support/fixture/UniversityFixture.java index f21b4b3b1..1b6e14593 100644 --- a/src/test/java/gg/agit/konect/support/fixture/UniversityFixture.java +++ b/src/test/java/gg/agit/konect/support/fixture/UniversityFixture.java @@ -1,5 +1,7 @@ package gg.agit.konect.support.fixture; +import org.springframework.test.util.ReflectionTestUtils; + import gg.agit.konect.domain.university.enums.Campus; import gg.agit.konect.domain.university.model.University; @@ -19,4 +21,14 @@ public static University create(String koreanName, Campus campus) { public static University createWithName(String koreanName) { return create(koreanName, Campus.MAIN); } + + public static University createWithId(Integer id) { + return createWithId(id, "한국기술교육대학교", Campus.MAIN); + } + + public static University createWithId(Integer id, String koreanName, Campus campus) { + University university = create(koreanName, campus); + ReflectionTestUtils.setField(university, "id", id); + return university; + } } diff --git a/src/test/java/gg/agit/konect/support/fixture/UserFixture.java b/src/test/java/gg/agit/konect/support/fixture/UserFixture.java index 50097e056..b0d8a9432 100644 --- a/src/test/java/gg/agit/konect/support/fixture/UserFixture.java +++ b/src/test/java/gg/agit/konect/support/fixture/UserFixture.java @@ -4,6 +4,8 @@ import gg.agit.konect.domain.user.enums.UserRole; import gg.agit.konect.domain.user.model.User; +import java.time.LocalDateTime; + public class UserFixture { public static User createUser(University university) { @@ -22,6 +24,28 @@ public static User createUser(University university, String name, String student .build(); } + public static User createUserWithId(University university, Integer id, String name, String studentNumber, + UserRole role) { + return User.builder() + .id(id) + .university(university) + .email(studentNumber + "@koreatech.ac.kr") + .name(name) + .studentNumber(studentNumber) + .role(role) + .isMarketingAgreement(true) + .imageUrl("https://example.com/profile.png") + .build(); + } + + public static User createUserWithId(Integer id, String name, UserRole role) { + return createUserWithId(UniversityFixture.create(), id, name, "2024" + String.format("%04d", id), role); + } + + public static User createUserWithId(Integer id, String studentNumber) { + return createUserWithId(UniversityFixture.create(), id, "테스트유저" + id, studentNumber, UserRole.USER); + } + public static User createAdmin(University university) { return User.builder() .university(university) @@ -33,4 +57,10 @@ public static User createAdmin(University university) { .imageUrl("https://example.com/admin.png") .build(); } + + public static User createWithdrawnUser(Integer id, String studentNumber, LocalDateTime deletedAt) { + User user = createUserWithId(id, studentNumber); + user.withdraw(deletedAt); + return user; + } } diff --git a/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatServiceTest.java new file mode 100644 index 000000000..45760b453 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatServiceTest.java @@ -0,0 +1,1371 @@ +package gg.agit.konect.unit.domain.chat.service; + +import static gg.agit.konect.domain.chat.service.ChatRoomMembershipService.SYSTEM_ADMIN_ID; +import static gg.agit.konect.global.code.ApiResponseCode.CANNOT_CREATE_CHAT_ROOM_WITH_SELF; +import static gg.agit.konect.global.code.ApiResponseCode.CANNOT_KICK_IN_NON_GROUP_ROOM; +import static gg.agit.konect.global.code.ApiResponseCode.CANNOT_KICK_ROOM_OWNER; +import static gg.agit.konect.global.code.ApiResponseCode.CANNOT_KICK_SELF; +import static gg.agit.konect.global.code.ApiResponseCode.CANNOT_LEAVE_GROUP_CHAT_ROOM; +import static gg.agit.konect.global.code.ApiResponseCode.FORBIDDEN_CHAT_ROOM_ACCESS; +import static gg.agit.konect.global.code.ApiResponseCode.FORBIDDEN_CHAT_ROOM_KICK; +import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_CHAT_ROOM; +import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_USER; +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.ArgumentMatchers.nullable; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.assertj.core.api.ThrowableAssert.ThrowingCallable; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.util.ReflectionTestUtils; + +import gg.agit.konect.domain.chat.dto.ChatMessageDetailResponse; +import gg.agit.konect.domain.chat.dto.ChatMessagePageResponse; +import gg.agit.konect.domain.chat.dto.ChatMessageSendRequest; +import gg.agit.konect.domain.chat.dto.ChatMuteResponse; +import gg.agit.konect.domain.chat.dto.ChatRoomCreateRequest; +import gg.agit.konect.domain.chat.dto.ChatRoomNameUpdateRequest; +import gg.agit.konect.domain.chat.dto.ChatRoomResponse; +import gg.agit.konect.domain.chat.enums.ChatType; +import gg.agit.konect.domain.chat.model.ChatMessage; +import gg.agit.konect.domain.chat.model.ChatRoom; +import gg.agit.konect.domain.chat.model.ChatRoomMember; +import gg.agit.konect.domain.chat.repository.ChatInviteQueryRepository; +import gg.agit.konect.domain.chat.repository.ChatMessageRepository; +import gg.agit.konect.domain.chat.repository.ChatRoomMemberRepository; +import gg.agit.konect.domain.chat.repository.ChatRoomRepository; +import gg.agit.konect.domain.chat.service.ChatPresenceService; +import gg.agit.konect.domain.chat.service.ChatRoomMembershipService; +import gg.agit.konect.domain.chat.service.ChatService; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.model.ClubMember; +import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.notification.enums.NotificationTargetType; +import gg.agit.konect.domain.notification.model.NotificationMuteSetting; +import gg.agit.konect.domain.notification.repository.NotificationMuteSettingRepository; +import gg.agit.konect.domain.notification.service.NotificationService; +import gg.agit.konect.domain.user.enums.UserRole; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.support.ServiceTestSupport; +import gg.agit.konect.support.fixture.ClubFixture; +import gg.agit.konect.support.fixture.ClubMemberFixture; +import gg.agit.konect.support.fixture.UniversityFixture; +import gg.agit.konect.support.fixture.UserFixture; + +class ChatServiceTest extends ServiceTestSupport { + + @Mock + private ChatRoomRepository chatRoomRepository; + + @Mock + private ChatMessageRepository chatMessageRepository; + + @Mock + private ChatRoomMemberRepository chatRoomMemberRepository; + + @Mock + private NotificationMuteSettingRepository notificationMuteSettingRepository; + + @Mock + private ClubMemberRepository clubMemberRepository; + + @Mock + private ChatInviteQueryRepository chatInviteQueryRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private ChatPresenceService chatPresenceService; + + @Mock + private ChatRoomMembershipService chatRoomMembershipService; + + @Mock + private NotificationService notificationService; + + @Mock + private ApplicationEventPublisher eventPublisher; + + @InjectMocks + private ChatService chatService; + + @Test + @DisplayName("createOrGetChatRoom은 자기 자신과의 direct room 생성을 거부한다") + void createOrGetChatRoomRejectsSelfChat() { + // given + Integer userId = 10; + User user = createUser(userId, "요청자", UserRole.USER); + given(userRepository.getById(userId)).willReturn(user); + + // when & then + assertErrorCode( + () -> chatService.createOrGetChatRoom(userId, new ChatRoomCreateRequest(userId)), + CANNOT_CREATE_CHAT_ROOM_WITH_SELF + ); + verify(chatRoomRepository, never()).save(any(ChatRoom.class)); + } + + @Test + @DisplayName("createOrGetChatRoom은 기존 direct room이 있으면 재사용하고 요청자 멤버십을 복구한다") + void createOrGetChatRoomReusesExistingDirectRoomAndReopensRequesterMembership() { + // given + Integer currentUserId = 10; + Integer targetUserId = 20; + User currentUser = createUser(currentUserId, "요청자", UserRole.USER); + User targetUser = createUser(targetUserId, "상대", UserRole.USER); + ChatRoom room = createRoom(1, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember requesterMember = createRoomMember(room, currentUser, false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + markMemberLeft(requesterMember, LocalDateTime.of(2026, 4, 11, 11, 0)); + ChatRoomMember targetMember = createRoomMember(room, targetUser, false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + + given(userRepository.getById(currentUserId)).willReturn(currentUser); + given(userRepository.getById(targetUserId)).willReturn(targetUser); + given(chatRoomRepository.findByTwoUsers(currentUserId, targetUserId, ChatType.DIRECT)) + .willReturn(Optional.of(room)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), currentUserId)) + .willReturn(Optional.of(requesterMember)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), targetUserId)) + .willReturn(Optional.of(targetMember)); + + // when + ChatRoomResponse response = chatService.createOrGetChatRoom(currentUserId, + new ChatRoomCreateRequest(targetUserId)); + + // then + assertThat(response.chatRoomId()).isEqualTo(room.getId()); + assertThat(requesterMember.hasLeft()).isFalse(); + assertThat(requesterMember.getVisibleMessageFrom()).isNotNull(); + verify(chatRoomRepository, never()).save(any(ChatRoom.class)); + verify(chatRoomMemberRepository, never()).save(any(ChatRoomMember.class)); + } + + @Test + @DisplayName("createOrGetChatRoom은 admin이 일반 사용자와 채팅할 때 system-admin room 경로를 사용한다") + void createOrGetChatRoomUsesSystemAdminRoomForAdminToUser() { + // given + Integer adminUserId = 99; + int targetUserId = 20; + User adminUser = createUser(adminUserId, "관리자", UserRole.ADMIN); + User targetUser = createUser(targetUserId, "일반 사용자", UserRole.USER); + User systemAdmin = createUser(SYSTEM_ADMIN_ID, "시스템관리자", UserRole.ADMIN); + ChatRoom room = createRoom(1, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + given(userRepository.getById(adminUserId)).willReturn(adminUser); + given(userRepository.getById(targetUserId)).willReturn(targetUser); + given(chatRoomRepository.findByTwoUsers(SYSTEM_ADMIN_ID, targetUserId, ChatType.DIRECT)) + .willReturn(Optional.empty()); + given(chatRoomRepository.save(any(ChatRoom.class))).willReturn(room); + given(userRepository.getById(SYSTEM_ADMIN_ID)).willReturn(systemAdmin); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), SYSTEM_ADMIN_ID)) + .willReturn(Optional.empty()); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), targetUserId)) + .willReturn(Optional.empty()); + given(chatRoomMemberRepository.findRoomMemberIdsByChatRoomIds(List.of(room.getId()))) + .willReturn(List.of( + new Object[] {room.getId(), SYSTEM_ADMIN_ID, room.getCreatedAt()}, + new Object[] {room.getId(), targetUserId, room.getCreatedAt()} + )); + + // when + ChatRoomResponse response = chatService.createOrGetChatRoom(adminUserId, + new ChatRoomCreateRequest(targetUserId)); + + // then + assertThat(response.chatRoomId()).isEqualTo(room.getId()); + verify(chatRoomMemberRepository, times(2)).save(any(ChatRoomMember.class)); + verify(chatRoomMemberRepository, never()).findByChatRoomIdAndUserId(room.getId(), adminUserId); + } + + @SuppressWarnings("unchecked") + @Test + @DisplayName("createGroupChatRoom은 초대 대상을 dedup 하고 자기 자신을 제외한 뒤 owner/member를 저장한다") + void createGroupChatRoomDeduplicatesInviteesAndSavesMembers() { + // given + Integer creatorId = 10; + User creator = createUser(creatorId, "생성자", UserRole.USER); + User user20 = createUser(20, "멤버1", UserRole.USER); + User user30 = createUser(30, "멤버2", UserRole.USER); + ChatRoom room = createRoom(50, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 12, 0)); + + given(userRepository.getById(creatorId)).willReturn(creator); + given(userRepository.findAllByIdIn(List.of(20, 30))).willReturn(List.of(user20, user30)); + given(chatRoomRepository.save(any(ChatRoom.class))).willReturn(room); + + // when + ChatRoomResponse response = chatService.createGroupChatRoom( + creatorId, + new ChatRoomCreateRequest.Group(List.of(creatorId, 20, 20, 30)) + ); + + // then + assertThat(response.chatRoomId()).isEqualTo(room.getId()); + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(chatRoomMemberRepository).saveAll(captor.capture()); + assertThat(captor.getValue()).hasSize(3); + assertThat(captor.getValue()) + .extracting(ChatRoomMember::getUserId, ChatRoomMember::isOwner) + .containsExactlyInAnyOrder( + org.assertj.core.groups.Tuple.tuple(creatorId, true), + org.assertj.core.groups.Tuple.tuple(20, false), + org.assertj.core.groups.Tuple.tuple(30, false) + ); + } + + @Test + @DisplayName("createGroupChatRoom은 자기 자신만 남거나 조회되지 않는 사용자가 있으면 실패한다") + void createGroupChatRoomRejectsInvalidInvitees() { + // given + Integer creatorId = 10; + User creator = createUser(creatorId, "생성자", UserRole.USER); + given(userRepository.getById(creatorId)).willReturn(creator); + given(userRepository.findAllByIdIn(List.of(20, 30))).willReturn(List.of(createUser(20, "멤버1", UserRole.USER))); + + // when & then + assertErrorCode( + () -> chatService.createGroupChatRoom(creatorId, new ChatRoomCreateRequest.Group(List.of(creatorId))), + CANNOT_CREATE_CHAT_ROOM_WITH_SELF + ); + assertErrorCode( + () -> chatService.createGroupChatRoom(creatorId, new ChatRoomCreateRequest.Group(List.of(20, 30))), + NOT_FOUND_USER + ); + } + + @Test + @DisplayName("leaveChatRoom은 club group room 나가기를 거부하고 direct room은 leftAt을 갱신한다") + void leaveChatRoomRejectsClubRoomAndMarksDirectRoomLeft() { + // given + Integer userId = 10; + ChatRoom directRoom = createRoom(1, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoom clubRoom = createClubRoom(2, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember directMember = createRoomMember(directRoom, createUser(userId, "사용자", UserRole.USER), false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + + given(chatRoomRepository.findById(directRoom.getId())).willReturn(Optional.of(directRoom)); + given(chatRoomRepository.findById(clubRoom.getId())).willReturn(Optional.of(clubRoom)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(directRoom.getId(), userId)) + .willReturn(Optional.of(directMember)); + + // when + chatService.leaveChatRoom(userId, directRoom.getId()); + + // then + assertThat(directMember.hasLeft()).isTrue(); + assertThat(directMember.getVisibleMessageFrom()).isNotNull(); + assertErrorCode(() -> chatService.leaveChatRoom(userId, clubRoom.getId()), CANNOT_LEAVE_GROUP_CHAT_ROOM); + } + + @Test + @DisplayName("leaveChatRoom은 일반 group room에서는 membership 삭제를 수행한다") + void leaveChatRoomDeletesMembershipForGroupRoom() { + // given + Integer userId = 10; + ChatRoom groupRoom = createRoom(3, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember member = createRoomMember(groupRoom, createUser(userId, "사용자", UserRole.USER), false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + + given(chatRoomRepository.findById(groupRoom.getId())).willReturn(Optional.of(groupRoom)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(groupRoom.getId(), userId)) + .willReturn(Optional.of(member)); + + // when + chatService.leaveChatRoom(userId, groupRoom.getId()); + + // then + verify(chatRoomMemberRepository).deleteByChatRoomIdAndUserId(groupRoom.getId(), userId); + } + + @Test + @DisplayName("kickMember는 비그룹방에서 멤버 강퇴를 거부한다") + void kickMemberRejectsNonGroupRoom() { + // given + Integer requesterId = 10; + Integer targetId = 20; + ChatRoom directRoom = createRoom(1, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + + given(chatRoomRepository.findById(directRoom.getId())).willReturn(Optional.of(directRoom)); + + // when & then + assertErrorCode(() -> chatService.kickMember(requesterId, directRoom.getId(), targetId), + CANNOT_KICK_IN_NON_GROUP_ROOM); + } + + @Test + @DisplayName("kickMember는 자기 자신을 강퇴할 수 없다") + void kickMemberRejectsSelfKick() { + // given + Integer requesterId = 10; + ChatRoom groupRoom = createRoom(2, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + + given(chatRoomRepository.findById(groupRoom.getId())).willReturn(Optional.of(groupRoom)); + + // when & then + assertErrorCode(() -> chatService.kickMember(requesterId, groupRoom.getId(), requesterId), CANNOT_KICK_SELF); + } + + @Test + @DisplayName("kickMember는 방장이 아닌 요청자의 강퇴를 거부한다") + void kickMemberRejectsNonOwnerRequester() { + // given + Integer requesterId = 10; + Integer targetId = 20; + ChatRoom groupRoom = createRoom(2, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember nonOwnerRequester = createRoomMember(groupRoom, createUser(requesterId, "요청자", UserRole.USER), + false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + + given(chatRoomRepository.findById(groupRoom.getId())).willReturn(Optional.of(groupRoom)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(groupRoom.getId(), requesterId)) + .willReturn(Optional.of(nonOwnerRequester)); + + // when & then + assertErrorCode(() -> chatService.kickMember(requesterId, groupRoom.getId(), targetId), + FORBIDDEN_CHAT_ROOM_KICK); + } + + @Test + @DisplayName("kickMember는 방장을 강퇴할 수 없다") + void kickMemberRejectsOwnerTarget() { + // given + Integer requesterId = 10; + Integer targetId = 20; + ChatRoom groupRoom = createRoom(2, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember ownerRequester = createRoomMember(groupRoom, createUser(requesterId, "방장", UserRole.USER), true, + LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember ownerTarget = createRoomMember(groupRoom, createUser(targetId, "대상 방장", UserRole.USER), true, + LocalDateTime.of(2026, 4, 11, 10, 0)); + + given(chatRoomRepository.findById(groupRoom.getId())).willReturn(Optional.of(groupRoom)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(groupRoom.getId(), requesterId)) + .willReturn(Optional.of(ownerRequester)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(groupRoom.getId(), targetId)) + .willReturn(Optional.of(ownerTarget)); + + // when & then + assertErrorCode(() -> chatService.kickMember(requesterId, groupRoom.getId(), targetId), CANNOT_KICK_ROOM_OWNER); + } + + @Test + @DisplayName("kickMember는 유효한 group room에서 target membership을 삭제한다") + void kickMemberDeletesTargetMembershipWhenValid() { + // given + Integer requesterId = 10; + Integer targetId = 20; + ChatRoom groupRoom = createRoom(2, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember ownerRequester = createRoomMember(groupRoom, createUser(requesterId, "방장", UserRole.USER), true, + LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember target = createRoomMember(groupRoom, createUser(targetId, "멤버", UserRole.USER), false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + + given(chatRoomRepository.findById(groupRoom.getId())).willReturn(Optional.of(groupRoom)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(groupRoom.getId(), requesterId)) + .willReturn(Optional.of(ownerRequester)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(groupRoom.getId(), targetId)) + .willReturn(Optional.of(target)); + + // when + chatService.kickMember(requesterId, groupRoom.getId(), targetId); + + // then + verify(chatRoomMemberRepository).deleteByChatRoomIdAndUserId(groupRoom.getId(), targetId); + } + + @Test + @DisplayName("toggleMute는 기존 setting이 false면 true로 토글한다") + void toggleMuteTogglesFromUnmutedToMuted() { + // given + Integer userId = 10; + Integer roomId = 1; + User user = createUser(userId, "사용자", UserRole.USER); + ChatRoom room = createRoom(roomId, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember member = createRoomMember(room, user, false, LocalDateTime.of(2026, 4, 11, 10, 0)); + NotificationMuteSetting setting = NotificationMuteSetting.of(NotificationTargetType.CHAT_ROOM, roomId, user, + false); + + given(chatRoomRepository.findById(roomId)).willReturn(Optional.of(room)); + given(userRepository.getById(userId)).willReturn(user); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(roomId, userId)).willReturn(Optional.of(member)); + given(notificationMuteSettingRepository.findByTargetTypeAndTargetIdAndUserId(NotificationTargetType.CHAT_ROOM, + roomId, userId)) + .willReturn(Optional.of(setting)); + + // when + ChatMuteResponse response = chatService.toggleMute(userId, roomId); + + // then + assertThat(response.isMuted()).isTrue(); + assertThat(setting.getIsMuted()).isTrue(); + verify(notificationMuteSettingRepository).save(setting); + } + + @Test + @DisplayName("toggleMute는 기존 setting이 true면 false로 토글한다 (unmute)") + void toggleMuteTogglesFromMutedToUnmuted() { + // given + Integer userId = 10; + Integer roomId = 1; + User user = createUser(userId, "사용자", UserRole.USER); + ChatRoom room = createRoom(roomId, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember member = createRoomMember(room, user, false, LocalDateTime.of(2026, 4, 11, 10, 0)); + NotificationMuteSetting setting = NotificationMuteSetting.of(NotificationTargetType.CHAT_ROOM, roomId, user, + true); + + given(chatRoomRepository.findById(roomId)).willReturn(Optional.of(room)); + given(userRepository.getById(userId)).willReturn(user); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(roomId, userId)).willReturn(Optional.of(member)); + given(notificationMuteSettingRepository.findByTargetTypeAndTargetIdAndUserId(NotificationTargetType.CHAT_ROOM, + roomId, userId)) + .willReturn(Optional.of(setting)); + + // when + ChatMuteResponse response = chatService.toggleMute(userId, roomId); + + // then + assertThat(response.isMuted()).isFalse(); + assertThat(setting.getIsMuted()).isFalse(); + verify(notificationMuteSettingRepository).save(setting); + } + + @Test + @DisplayName("toggleMute는 기존 setting이 없으면 muted=true로 저장한다") + void toggleMuteCreatesNewMutedSettingWhenNoneExists() { + // given + Integer userId = 10; + Integer roomId = 1; + User user = createUser(userId, "사용자", UserRole.USER); + ChatRoom room = createRoom(roomId, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember member = createRoomMember(room, user, false, LocalDateTime.of(2026, 4, 11, 10, 0)); + + given(chatRoomRepository.findById(roomId)).willReturn(Optional.of(room)); + given(userRepository.getById(userId)).willReturn(user); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(roomId, userId)).willReturn(Optional.of(member)); + given(notificationMuteSettingRepository.findByTargetTypeAndTargetIdAndUserId(NotificationTargetType.CHAT_ROOM, + roomId, userId)) + .willReturn(Optional.empty()); + + // when + ChatMuteResponse response = chatService.toggleMute(userId, roomId); + + // then + assertThat(response.isMuted()).isTrue(); + verify(notificationMuteSettingRepository).save(any(NotificationMuteSetting.class)); + } + + @Test + @DisplayName("getMessages는 direct room에서 direct 전용 readAt 갱신 경로를 사용한다") + void getMessagesUsesDirectReadPath() { + // given + Integer userId = 10; + User user = createUser(userId, "사용자", UserRole.USER); + ChatRoom directRoom = createRoom(1, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember directMember = createRoomMember(directRoom, user, false, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatMessage directMessage = createMessage(100, directRoom, createUser(20, "상대", UserRole.USER), "direct", + LocalDateTime.of(2026, 4, 11, 10, 1)); + + given(chatRoomRepository.findById(directRoom.getId())).willReturn(Optional.of(directRoom)); + given(userRepository.getById(userId)).willReturn(user); + given(chatRoomMemberRepository.findByChatRoomId(directRoom.getId())).willReturn(List.of(directMember)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(directRoom.getId(), userId)).willReturn( + Optional.of(directMember)); + given(chatMessageRepository.findByChatRoomId(eq(directRoom.getId()), nullable(LocalDateTime.class), + eq(PageRequest.of(0, 20)))) + .willReturn(new PageImpl<>(List.of(directMessage), PageRequest.of(0, 20), 1)); + + // when + ChatMessagePageResponse response = chatService.getMessages(userId, directRoom.getId(), 1, 20); + + // then + assertThat(response.messages()).hasSize(1); + verify(chatRoomMembershipService).updateDirectRoomLastReadAt(eq(directRoom.getId()), eq(user), + any(LocalDateTime.class), eq(directRoom)); + verify(chatPresenceService).recordPresence(directRoom.getId(), userId); + } + + @Test + @DisplayName("getMessages는 club group room에서 club membership 보정과 일반 readAt 갱신을 수행한다") + void getMessagesUsesClubGroupReadPath() { + // given + Integer userId = 10; + User user = createUser(userId, "사용자", UserRole.USER); + ChatRoom clubRoom = createClubRoom(2, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember clubRoomMember = createRoomMember(clubRoom, user, false, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatMessage clubMessage = createMessage(200, clubRoom, user, "club", LocalDateTime.of(2026, 4, 11, 10, 2)); + + given(chatRoomRepository.findById(clubRoom.getId())).willReturn(Optional.of(clubRoom)); + given(userRepository.getById(userId)).willReturn(user); + given(chatRoomMemberRepository.findByChatRoomId(clubRoom.getId())).willReturn(List.of(clubRoomMember)); + given(chatMessageRepository.countByChatRoomId(clubRoom.getId(), null)).willReturn(1L); + given(chatMessageRepository.findByChatRoomId(eq(clubRoom.getId()), nullable(LocalDateTime.class), + eq(PageRequest.of(0, 20)))) + .willReturn(new PageImpl<>(List.of(clubMessage), PageRequest.of(0, 20), 1)); + + // when + ChatMessagePageResponse response = chatService.getMessages(userId, clubRoom.getId(), 1, 20); + + // then + assertThat(response.clubId()).isEqualTo(clubRoom.getClub().getId()); + verify(chatRoomMembershipService).ensureClubRoomMember(clubRoom.getId(), userId); + verify(chatRoomMembershipService).updateLastReadAt(eq(clubRoom.getId()), eq(userId), any(LocalDateTime.class)); + verify(chatPresenceService).recordPresence(clubRoom.getId(), userId); + } + + @Test + @DisplayName("getMessages는 group room에서 접근 검증 후 일반 readAt 갱신 경로를 사용한다") + void getMessagesUsesGroupReadPath() { + // given + Integer userId = 10; + User user = createUser(userId, "사용자", UserRole.USER); + ChatRoom groupRoom = createRoom(3, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember groupMember = createRoomMember(groupRoom, user, false, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatMessage groupMessage = createMessage(300, groupRoom, user, "group", LocalDateTime.of(2026, 4, 11, 10, 3)); + + given(chatRoomRepository.findById(groupRoom.getId())).willReturn(Optional.of(groupRoom)); + given(userRepository.getById(userId)).willReturn(user); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(groupRoom.getId(), userId)).willReturn( + Optional.of(groupMember)); + given(chatRoomMemberRepository.findByChatRoomId(groupRoom.getId())).willReturn(List.of(groupMember)); + given(chatMessageRepository.countByChatRoomId(groupRoom.getId(), null)).willReturn(1L); + given(chatMessageRepository.findByChatRoomId(eq(groupRoom.getId()), nullable(LocalDateTime.class), + eq(PageRequest.of(0, 20)))) + .willReturn(new PageImpl<>(List.of(groupMessage), PageRequest.of(0, 20), 1)); + + // when + ChatMessagePageResponse response = chatService.getMessages(userId, groupRoom.getId(), 1, 20); + + // then + assertThat(response.clubId()).isNull(); + verify(chatRoomMembershipService).updateLastReadAt(eq(groupRoom.getId()), eq(userId), any(LocalDateTime.class)); + verify(chatPresenceService).recordPresence(groupRoom.getId(), userId); + } + + @Test + @DisplayName("getMessages는 group room 비회원 요청을 거부한다") + void getMessagesRejectsGroupRoomOutsider() { + // given + Integer userId = 10; + ChatRoom groupRoom = createRoom(3, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + given(chatRoomRepository.findById(groupRoom.getId())).willReturn(Optional.of(groupRoom)); + given(userRepository.getById(userId)).willReturn(createUser(userId, "사용자", UserRole.USER)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(groupRoom.getId(), userId)).willReturn( + Optional.empty()); + + // when & then + assertErrorCode( + () -> chatService.getMessages(userId, groupRoom.getId(), 1, 20), + FORBIDDEN_CHAT_ROOM_ACCESS + ); + } + + // ===== createOrGetChatRoom additional ===== + + @Test + @DisplayName("createOrGetChatRoom은 기존 방이 없으면 새 direct room을 생성한다") + void createOrGetChatRoomCreatesNewDirectRoomWhenNoneExists() { + // given + Integer currentUserId = 10; + Integer targetUserId = 20; + User currentUser = createUser(currentUserId, "요청자", UserRole.USER); + User targetUser = createUser(targetUserId, "상대", UserRole.USER); + ChatRoom newRoom = createRoom(1, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + + given(userRepository.getById(currentUserId)).willReturn(currentUser); + given(userRepository.getById(targetUserId)).willReturn(targetUser); + given(chatRoomRepository.findByTwoUsers(currentUserId, targetUserId, ChatType.DIRECT)) + .willReturn(Optional.empty()); + given(chatRoomRepository.save(any(ChatRoom.class))).willReturn(newRoom); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(newRoom.getId(), currentUserId)) + .willReturn(Optional.empty()); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(newRoom.getId(), targetUserId)) + .willReturn(Optional.empty()); + + // when + ChatRoomResponse response = chatService.createOrGetChatRoom(currentUserId, + new ChatRoomCreateRequest(targetUserId)); + + // then + assertThat(response.chatRoomId()).isEqualTo(newRoom.getId()); + verify(chatRoomRepository).save(any(ChatRoom.class)); + verify(chatRoomMemberRepository, times(2)).save(any(ChatRoomMember.class)); + } + + @Test + @DisplayName("createOrGetChatRoom은 admin이 admin과 채팅할 때 일반 direct 경로를 사용한다") + void createOrGetChatRoomTreatsAdminToAdminAsNormalDirect() { + // given + Integer adminId1 = 99; + Integer adminId2 = 98; + User admin1 = createUser(adminId1, "관리자1", UserRole.ADMIN); + User admin2 = createUser(adminId2, "관리자2", UserRole.ADMIN); + ChatRoom room = createRoom(1, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember member1 = createRoomMember(room, admin1, false, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember member2 = createRoomMember(room, admin2, false, LocalDateTime.of(2026, 4, 11, 10, 0)); + + given(userRepository.getById(adminId1)).willReturn(admin1); + given(userRepository.getById(adminId2)).willReturn(admin2); + given(chatRoomRepository.findByTwoUsers(adminId1, adminId2, ChatType.DIRECT)) + .willReturn(Optional.of(room)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), adminId1)) + .willReturn(Optional.of(member1)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), adminId2)) + .willReturn(Optional.of(member2)); + + // when + ChatRoomResponse response = chatService.createOrGetChatRoom(adminId1, new ChatRoomCreateRequest(adminId2)); + + // then + assertThat(response.chatRoomId()).isEqualTo(room.getId()); + verify(chatRoomRepository, never()).findByTwoUsers(eq(SYSTEM_ADMIN_ID), any(), any()); + } + + // ===== createOrGetAdminChatRoom ===== + + @Test + @DisplayName("createOrGetAdminChatRoom은 admin이 없으면 NOT_FOUND_USER를 던진다") + void createOrGetAdminChatRoomThrowsWhenNoAdminExists() { + // given + given(userRepository.findFirstByRoleAndDeletedAtIsNullOrderByIdAsc(UserRole.ADMIN)) + .willReturn(Optional.empty()); + + // when & then + assertErrorCode(() -> chatService.createOrGetAdminChatRoom(10), NOT_FOUND_USER); + } + + // ===== leaveChatRoom additional ===== + + @Test + @DisplayName("leaveChatRoom은 존재하지 않는 방에 대해 NOT_FOUND_CHAT_ROOM을 던진다") + void leaveChatRoomThrowsWhenRoomNotFound() { + // given + given(chatRoomRepository.findById(999)).willReturn(Optional.empty()); + + // when & then + assertErrorCode(() -> chatService.leaveChatRoom(10, 999), NOT_FOUND_CHAT_ROOM); + } + + @Test + @DisplayName("leaveChatRoom은 멤버가 아닌 사용자에 대해 FORBIDDEN_CHAT_ROOM_ACCESS를 던진다") + void leaveChatRoomThrowsWhenNotMember() { + // given + Integer userId = 10; + ChatRoom directRoom = createRoom(1, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + + given(chatRoomRepository.findById(directRoom.getId())).willReturn(Optional.of(directRoom)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(directRoom.getId(), userId)) + .willReturn(Optional.empty()); + + // when & then + assertErrorCode(() -> chatService.leaveChatRoom(userId, directRoom.getId()), FORBIDDEN_CHAT_ROOM_ACCESS); + } + + // ===== kickMember additional ===== + + @Test + @DisplayName("kickMember는 존재하지 않는 방에 대해 NOT_FOUND_CHAT_ROOM을 던진다") + void kickMemberThrowsWhenRoomNotFound() { + // given + given(chatRoomRepository.findById(999)).willReturn(Optional.empty()); + + // when & then + assertErrorCode(() -> chatService.kickMember(10, 999, 20), NOT_FOUND_CHAT_ROOM); + } + + @Test + @DisplayName("kickMember는 club group room에서도 거부한다") + void kickMemberRejectsClubGroupRoom() { + // given + ChatRoom clubRoom = createRoom(1, ChatType.CLUB_GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + given(chatRoomRepository.findById(clubRoom.getId())).willReturn(Optional.of(clubRoom)); + + // when & then + assertErrorCode(() -> chatService.kickMember(10, clubRoom.getId(), 20), CANNOT_KICK_IN_NON_GROUP_ROOM); + } + + @Test + @DisplayName("kickMember는 요청자가 멤버가 아니면 FORBIDDEN_CHAT_ROOM_ACCESS를 던진다") + void kickMemberThrowsWhenRequesterNotMember() { + // given + Integer requesterId = 10; + Integer targetId = 20; + ChatRoom groupRoom = createRoom(1, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + + given(chatRoomRepository.findById(groupRoom.getId())).willReturn(Optional.of(groupRoom)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(groupRoom.getId(), requesterId)) + .willReturn(Optional.empty()); + + // when & then + assertErrorCode(() -> chatService.kickMember(requesterId, groupRoom.getId(), targetId), + FORBIDDEN_CHAT_ROOM_ACCESS); + } + + @Test + @DisplayName("kickMember는 target이 멤버가 아니면 FORBIDDEN_CHAT_ROOM_ACCESS를 던진다") + void kickMemberThrowsWhenTargetNotMember() { + // given + Integer requesterId = 10; + Integer targetId = 20; + ChatRoom groupRoom = createRoom(1, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember owner = createRoomMember(groupRoom, createUser(requesterId, "방장", UserRole.USER), true, + LocalDateTime.of(2026, 4, 11, 10, 0)); + + given(chatRoomRepository.findById(groupRoom.getId())).willReturn(Optional.of(groupRoom)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(groupRoom.getId(), requesterId)) + .willReturn(Optional.of(owner)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(groupRoom.getId(), targetId)) + .willReturn(Optional.empty()); + + // when & then + assertErrorCode(() -> chatService.kickMember(requesterId, groupRoom.getId(), targetId), + FORBIDDEN_CHAT_ROOM_ACCESS); + } + + // ===== getMessages additional ===== + + @Test + @DisplayName("getMessages는 존재하지 않는 방에 대해 NOT_FOUND_CHAT_ROOM을 던진다") + void getMessagesThrowsWhenRoomNotFound() { + // given + given(chatRoomRepository.findById(999)).willReturn(Optional.empty()); + + // when & then + assertErrorCode(() -> chatService.getMessages(10, 999, 1, 20), NOT_FOUND_CHAT_ROOM); + } + + @Test + @DisplayName("getMessages는 admin이 system admin 방을 조회할 때 전용 경로를 사용한다") + void getMessagesReturnsAdminSystemRoomMessages() { + // given + Integer adminId = 99; + User admin = createUser(adminId, "관리자", UserRole.ADMIN); + User systemAdmin = createUser(SYSTEM_ADMIN_ID, "시스템관리자", UserRole.ADMIN); + User targetUser = createUser(20, "사용자", UserRole.USER); + ChatRoom systemAdminRoom = createRoom(1, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember systemAdminMember = createRoomMember(systemAdminRoom, systemAdmin, false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember targetMember = createRoomMember(systemAdminRoom, targetUser, false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatMessage message = createMessage(100, systemAdminRoom, admin, "문의", + LocalDateTime.of(2026, 4, 11, 10, 1)); + + given(chatRoomRepository.findById(systemAdminRoom.getId())).willReturn(Optional.of(systemAdminRoom)); + given(userRepository.getById(adminId)).willReturn(admin); + given(chatRoomMemberRepository.findRoomMemberIdsByChatRoomIds(List.of(systemAdminRoom.getId()))) + .willReturn(List.of( + new Object[] {systemAdminRoom.getId(), SYSTEM_ADMIN_ID, systemAdminRoom.getCreatedAt()}, + new Object[] {systemAdminRoom.getId(), 20, systemAdminRoom.getCreatedAt()} + )); + given(chatRoomMemberRepository.findByChatRoomId(systemAdminRoom.getId())) + .willReturn(List.of(systemAdminMember, targetMember)); + given(chatMessageRepository.findByChatRoomId(eq(systemAdminRoom.getId()), nullable(LocalDateTime.class), + eq(PageRequest.of(0, 20)))) + .willReturn(new PageImpl<>(List.of(message), PageRequest.of(0, 20), 1)); + + // when + ChatMessagePageResponse response = chatService.getMessages(adminId, systemAdminRoom.getId(), 1, 20); + + // then + assertThat(response.messages()).hasSize(1); + verify(chatRoomMembershipService).updateLastReadAt(eq(systemAdminRoom.getId()), eq(SYSTEM_ADMIN_ID), + any(LocalDateTime.class)); + verify(chatPresenceService).recordPresence(systemAdminRoom.getId(), adminId); + } + + // ===== sendMessage ===== + + @Test + @DisplayName("sendMessage는 존재하지 않는 방에 대해 NOT_FOUND_CHAT_ROOM을 던진다") + void sendMessageThrowsWhenRoomNotFound() { + // given + given(chatRoomRepository.findById(999)).willReturn(Optional.empty()); + + // when & then + assertErrorCode(() -> chatService.sendMessage(10, 999, new ChatMessageSendRequest("hi")), NOT_FOUND_CHAT_ROOM); + } + + @Test + @DisplayName("sendMessage는 direct room에서 메시지를 저장하고 알림을 보낸다") + void sendMessageInDirectRoomSavesMessageAndSendsNotification() { + // given + Integer senderId = 10; + Integer receiverId = 20; + User sender = createUser(senderId, "보낸이", UserRole.USER); + User receiver = createUser(receiverId, "받는이", UserRole.USER); + ChatRoom directRoom = createRoom(1, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember senderMember = createRoomMember(directRoom, sender, false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember receiverMember = createRoomMember(directRoom, receiver, false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatMessage savedMessage = createMessage(100, directRoom, sender, "hello", + LocalDateTime.of(2026, 4, 11, 10, 1)); + + given(chatRoomRepository.findById(directRoom.getId())).willReturn(Optional.of(directRoom)); + given(userRepository.getById(senderId)).willReturn(sender); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(directRoom.getId(), senderId)) + .willReturn(Optional.of(senderMember)); + given(chatRoomMemberRepository.findByChatRoomId(directRoom.getId())) + .willReturn(List.of(senderMember, receiverMember)); + given(chatMessageRepository.save(any(ChatMessage.class))).willReturn(savedMessage); + given(chatRoomMemberRepository.updateLastReadAtIfOlder(eq(directRoom.getId()), eq(senderId), + any(LocalDateTime.class))) + .willReturn(1); + + // when + ChatMessageDetailResponse response = chatService.sendMessage(senderId, directRoom.getId(), + new ChatMessageSendRequest("hello")); + + // then + assertThat(response.messageId()).isEqualTo(savedMessage.getId()); + assertThat(response.content()).isEqualTo("hello"); + assertThat(response.senderId()).isEqualTo(senderId); + assertThat(response.isMine()).isTrue(); + verify(chatMessageRepository).save(any(ChatMessage.class)); + verify(notificationService).sendChatNotification(eq(receiverId), eq(directRoom.getId()), eq("보낸이"), + eq("hello")); + } + + @Test + @DisplayName("sendMessage는 group room에서 메시지를 저장하고 그룹 알림을 보낸다") + void sendMessageInGroupRoomSavesMessageAndSendsGroupNotification() { + // given + Integer senderId = 10; + User sender = createUser(senderId, "보낸이", UserRole.USER); + ChatRoom groupRoom = createRoom(1, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember senderMember = createRoomMember(groupRoom, sender, false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatMessage savedMessage = createMessage(100, groupRoom, sender, "hello", + LocalDateTime.of(2026, 4, 11, 10, 1)); + + given(chatRoomRepository.findById(groupRoom.getId())).willReturn(Optional.of(groupRoom)); + given(userRepository.getById(senderId)).willReturn(sender); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(groupRoom.getId(), senderId)) + .willReturn(Optional.of(senderMember)); + given(chatMessageRepository.save(any(ChatMessage.class))).willReturn(savedMessage); + given(chatRoomMemberRepository.updateLastReadAtIfOlder(eq(groupRoom.getId()), eq(senderId), + any(LocalDateTime.class))) + .willReturn(1); + given(chatRoomMemberRepository.findByChatRoomId(groupRoom.getId())) + .willReturn(List.of(senderMember)); + + // when + ChatMessageDetailResponse response = chatService.sendMessage(senderId, groupRoom.getId(), + new ChatMessageSendRequest("hello")); + + // then + assertThat(response.messageId()).isEqualTo(savedMessage.getId()); + assertThat(response.content()).isEqualTo("hello"); + assertThat(response.isMine()).isTrue(); + verify(notificationService).sendGroupChatNotification( + eq(groupRoom.getId()), eq(senderId), eq("그룹 채팅"), eq("보낸이"), eq("hello"), + any(List.class) + ); + } + + @Test + @DisplayName("sendMessage는 group room 멤버의 hasLeft 상태면 FORBIDDEN_CHAT_ROOM_ACCESS를 던진다") + void sendMessageInGroupRoomRejectsLeftMember() { + // given + Integer senderId = 10; + User sender = createUser(senderId, "보낸이", UserRole.USER); + ChatRoom groupRoom = createRoom(1, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember leftMember = createRoomMember(groupRoom, sender, false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + markMemberLeft(leftMember, LocalDateTime.of(2026, 4, 11, 12, 0)); + + given(chatRoomRepository.findById(groupRoom.getId())).willReturn(Optional.of(groupRoom)); + given(userRepository.getById(senderId)).willReturn(sender); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(groupRoom.getId(), senderId)) + .willReturn(Optional.of(leftMember)); + + // when & then + assertErrorCode( + () -> chatService.sendMessage(senderId, groupRoom.getId(), new ChatMessageSendRequest("hello")), + FORBIDDEN_CHAT_ROOM_ACCESS + ); + verify(chatMessageRepository, never()).save(any(ChatMessage.class)); + } + + @Test + @DisplayName("sendMessage는 club room에서 메시지를 저장하고 그룹 알림을 보낸다") + void sendMessageInClubRoomSavesMessageAndSendsGroupNotification() { + // given + Integer senderId = 10; + User sender = createUser(senderId, "보낸이", UserRole.USER); + Club club = ClubFixture.createWithId(UniversityFixture.createWithId(1), 77, "BCSD"); + ChatRoom clubRoom = ChatRoom.clubGroupOf(club); + ReflectionTestUtils.setField(clubRoom, "id", 1); + ReflectionTestUtils.setField(clubRoom, "createdAt", LocalDateTime.of(2026, 4, 11, 10, 0)); + ClubMember clubMember = ClubMemberFixture.createMember(club, sender); + ReflectionTestUtils.setField(clubMember, "createdAt", LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember senderRoomMember = createRoomMember(clubRoom, sender, false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatMessage savedMessage = createMessage(100, clubRoom, sender, "hello", + LocalDateTime.of(2026, 4, 11, 10, 1)); + + given(chatRoomRepository.findById(clubRoom.getId())).willReturn(Optional.of(clubRoom)); + given(clubMemberRepository.getByClubIdAndUserId(club.getId(), senderId)).willReturn(clubMember); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(clubRoom.getId(), senderId)) + .willReturn(Optional.of(senderRoomMember)); + given(chatMessageRepository.save(any(ChatMessage.class))).willReturn(savedMessage); + given(chatRoomMemberRepository.updateLastReadAtIfOlder(eq(clubRoom.getId()), eq(senderId), + any(LocalDateTime.class))) + .willReturn(1); + given(chatRoomMemberRepository.findByChatRoomId(clubRoom.getId())) + .willReturn(List.of(senderRoomMember)); + + // when + ChatMessageDetailResponse response = chatService.sendMessage(senderId, clubRoom.getId(), + new ChatMessageSendRequest("hello")); + + // then + assertThat(response.messageId()).isEqualTo(savedMessage.getId()); + assertThat(response.content()).isEqualTo("hello"); + verify(notificationService).sendGroupChatNotification( + eq(clubRoom.getId()), eq(senderId), eq("BCSD"), eq("보낸이"), eq("hello"), + any(List.class) + ); + } + + @Test + @DisplayName("sendMessage는 admin이 system admin room에 보내면 멤버십 체크를 건너뛰고 lastReadAt 업데이트도 하지 않는다") + void sendMessageAdminBypassesMembershipInSystemAdminRoom() { + // given + Integer adminId = 99; + Integer targetUserId = 20; + User admin = createUser(adminId, "관리자", UserRole.ADMIN); + User systemAdmin = createUser(SYSTEM_ADMIN_ID, "시스템관리자", UserRole.ADMIN); + User targetUser = createUser(targetUserId, "사용자", UserRole.USER); + ChatRoom systemAdminRoom = createRoom(1, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember systemAdminMember = createRoomMember(systemAdminRoom, systemAdmin, false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember targetMember = createRoomMember(systemAdminRoom, targetUser, false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatMessage savedMessage = createMessage(100, systemAdminRoom, admin, "문의", + LocalDateTime.of(2026, 4, 11, 10, 1)); + + given(chatRoomRepository.findById(systemAdminRoom.getId())).willReturn(Optional.of(systemAdminRoom)); + given(userRepository.getById(adminId)).willReturn(admin); + given(chatRoomMemberRepository.findRoomMemberIdsByChatRoomIds(List.of(systemAdminRoom.getId()))) + .willReturn(List.of( + new Object[] {systemAdminRoom.getId(), SYSTEM_ADMIN_ID, systemAdminRoom.getCreatedAt()}, + new Object[] {systemAdminRoom.getId(), targetUserId, systemAdminRoom.getCreatedAt()} + )); + given(chatRoomMemberRepository.findByChatRoomId(systemAdminRoom.getId())) + .willReturn(List.of(systemAdminMember, targetMember)); + given(chatMessageRepository.save(any(ChatMessage.class))).willReturn(savedMessage); + + // when + ChatMessageDetailResponse response = chatService.sendMessage(adminId, systemAdminRoom.getId(), + new ChatMessageSendRequest("문의")); + + // then + assertThat(response.content()).isEqualTo("문의"); + assertThat(response.isMine()).isTrue(); + // 멤버십 조회를 건너뛰어야 한다 + verify(chatRoomMemberRepository, never()).findByChatRoomIdAndUserId(systemAdminRoom.getId(), adminId); + // admin은 lastReadAt 업데이트를 하지 않는다 + verify(chatRoomMemberRepository, never()).updateLastReadAtIfOlder(eq(systemAdminRoom.getId()), eq(adminId), + any(LocalDateTime.class)); + // 비관리자에게 알림이 전송되어야 한다 + verify(notificationService).sendChatNotification(eq(targetUserId), eq(systemAdminRoom.getId()), eq("관리자"), + eq("문의")); + } + + // ===== toggleMute additional ===== + + @Test + @DisplayName("toggleMute는 존재하지 않는 방에 대해 NOT_FOUND_CHAT_ROOM을 던진다") + void toggleMuteThrowsWhenRoomNotFound() { + // given + given(chatRoomRepository.findById(999)).willReturn(Optional.empty()); + + // when & then + assertErrorCode(() -> chatService.toggleMute(10, 999), NOT_FOUND_CHAT_ROOM); + } + + @Test + @DisplayName("toggleMute는 group room에서 멤버가 아니면 FORBIDDEN_CHAT_ROOM_ACCESS를 던진다") + void toggleMuteRejectsNonMemberInGroupRoom() { + // given + Integer userId = 10; + ChatRoom groupRoom = createRoom(1, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + + given(chatRoomRepository.findById(groupRoom.getId())).willReturn(Optional.of(groupRoom)); + given(userRepository.getById(userId)).willReturn(createUser(userId, "사용자", UserRole.USER)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(groupRoom.getId(), userId)) + .willReturn(Optional.empty()); + + // when & then + assertErrorCode(() -> chatService.toggleMute(userId, groupRoom.getId()), FORBIDDEN_CHAT_ROOM_ACCESS); + } + + // ===== updateChatRoomName ===== + + @Test + @DisplayName("updateChatRoomName은 멤버의 custom room name을 업데이트한다") + void updateChatRoomNameUpdatesCustomNameForMember() { + // given + Integer userId = 10; + User user = createUser(userId, "사용자", UserRole.USER); + ChatRoom groupRoom = createRoom(1, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember member = createRoomMember(groupRoom, user, false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + + given(chatRoomRepository.findById(groupRoom.getId())).willReturn(Optional.of(groupRoom)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(groupRoom.getId(), userId)) + .willReturn(Optional.of(member)); + + // when + chatService.updateChatRoomName(userId, groupRoom.getId(), new ChatRoomNameUpdateRequest("내 채팅방")); + + // then + assertThat(member.getCustomRoomName()).isEqualTo("내 채팅방"); + } + + @Test + @DisplayName("updateChatRoomName은 멤버가 아니면 FORBIDDEN_CHAT_ROOM_ACCESS를 던진다") + void updateChatRoomNameRejectsNonMember() { + // given + Integer userId = 10; + ChatRoom groupRoom = createRoom(1, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + + given(chatRoomRepository.findById(groupRoom.getId())).willReturn(Optional.of(groupRoom)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(groupRoom.getId(), userId)) + .willReturn(Optional.empty()); + + // when & then + assertErrorCode( + () -> chatService.updateChatRoomName(userId, groupRoom.getId(), new ChatRoomNameUpdateRequest("이름")), + FORBIDDEN_CHAT_ROOM_ACCESS + ); + } + + @Test + @DisplayName("updateChatRoomName은 null이나 빈 이름을 null로 정규화한다") + void updateChatRoomNameNormalizesNullName() { + // given + Integer userId = 10; + User user = createUser(userId, "사용자", UserRole.USER); + ChatRoom groupRoom = createRoom(1, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember member = createRoomMember(groupRoom, user, false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + member.updateCustomRoomName("기존 이름"); + + given(chatRoomRepository.findById(groupRoom.getId())).willReturn(Optional.of(groupRoom)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(groupRoom.getId(), userId)) + .willReturn(Optional.of(member)); + + // when + chatService.updateChatRoomName(userId, groupRoom.getId(), new ChatRoomNameUpdateRequest(null)); + + // then + assertThat(member.getCustomRoomName()).isNull(); + } + + // ===== getMessages with messageId (US-003) ===== + + @Test + @DisplayName("getMessages는 messageId가 null이면 기존 동작과 동일하게 동작한다") + void getMessagesWithNullMessageIdBehavesIdentically() { + // given + Integer userId = 10; + User user = createUser(userId, "사용자", UserRole.USER); + ChatRoom groupRoom = createRoom(3, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember groupMember = createRoomMember(groupRoom, user, false, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatMessage groupMessage = createMessage(300, groupRoom, user, "group", LocalDateTime.of(2026, 4, 11, 10, 3)); + + given(chatRoomRepository.findById(groupRoom.getId())).willReturn(Optional.of(groupRoom)); + given(userRepository.getById(userId)).willReturn(user); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(groupRoom.getId(), userId)).willReturn( + Optional.of(groupMember)); + given(chatRoomMemberRepository.findByChatRoomId(groupRoom.getId())).willReturn(List.of(groupMember)); + given(chatMessageRepository.countByChatRoomId(groupRoom.getId(), null)).willReturn(1L); + given(chatMessageRepository.findByChatRoomId(eq(groupRoom.getId()), nullable(LocalDateTime.class), + eq(PageRequest.of(0, 20)))) + .willReturn(new PageImpl<>(List.of(groupMessage), PageRequest.of(0, 20), 1)); + + // when — 기존 4-arg 오버로드 호출 + ChatMessagePageResponse response = chatService.getMessages(userId, groupRoom.getId(), 1, 20); + + // then + assertThat(response.messages()).hasSize(1); + verify(chatMessageRepository, never()).findByIdWithChatRoom(any()); + verify(chatMessageRepository, never()).countNewerMessagesByChatRoomId(any(), any(), any(), any()); + } + + @Test + @DisplayName("getMessages는 존재하지 않는 messageId에 대해 NOT_FOUND_CHAT_ROOM을 던진다") + void getMessagesWithMessageIdThrowsWhenMessageNotFound() { + // given + Integer userId = 10; + User user = createUser(userId, "사용자", UserRole.USER); + ChatRoom groupRoom = createRoom(1, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember groupMember = createRoomMember(groupRoom, user, false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + + given(chatRoomRepository.findById(groupRoom.getId())).willReturn(Optional.of(groupRoom)); + given(userRepository.getById(userId)).willReturn(user); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(groupRoom.getId(), userId)) + .willReturn(Optional.of(groupMember)); + given(chatMessageRepository.findByIdWithChatRoom(999)).willReturn(Optional.empty()); + + // when & then + assertErrorCode( + () -> chatService.getMessages(userId, groupRoom.getId(), 1, 20, 999), + NOT_FOUND_CHAT_ROOM + ); + } + + @Test + @DisplayName("getMessages는 다른 채팅방의 messageId에 대해 NOT_FOUND_CHAT_ROOM을 던진다") + void getMessagesWithMessageIdThrowsWhenMessageBelongsToOtherRoom() { + // given + Integer userId = 10; + User user = createUser(userId, "사용자", UserRole.USER); + ChatRoom groupRoom = createRoom(1, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoom otherRoom = createRoom(2, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember groupMember = createRoomMember(groupRoom, user, false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + User sender = createUser(20, "작성자", UserRole.USER); + ChatMessage otherRoomMessage = createMessage(100, otherRoom, sender, "다른 방 메시지", + LocalDateTime.of(2026, 4, 11, 10, 1)); + + given(chatRoomRepository.findById(groupRoom.getId())).willReturn(Optional.of(groupRoom)); + given(userRepository.getById(userId)).willReturn(user); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(groupRoom.getId(), userId)) + .willReturn(Optional.of(groupMember)); + given(chatMessageRepository.findByIdWithChatRoom(100)).willReturn(Optional.of(otherRoomMessage)); + + // when & then + assertErrorCode( + () -> chatService.getMessages(userId, groupRoom.getId(), 1, 20, 100), + NOT_FOUND_CHAT_ROOM + ); + } + + @Test + @DisplayName("getMessages는 group room에서 올바른 messageId 제공 시 계산된 페이지를 반환한다") + void getMessagesWithMessageIdCalculatesCorrectPageInGroupRoom() { + // given + Integer userId = 10; + User user = createUser(userId, "사용자", UserRole.USER); + ChatRoom groupRoom = createRoom(1, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember groupMember = createRoomMember(groupRoom, user, false, LocalDateTime.of(2026, 4, 11, 10, 0)); + + // 타겟 메시지: roomId=1, id=50, createdAt=14:00 + ChatMessage targetMessage = createMessage(50, groupRoom, user, "찾는 메시지", + LocalDateTime.of(2026, 4, 11, 14, 0)); + + // 타겟 메시지보다 최신인 메시지가 25개 → page = 25/20 + 1 = 2 + ChatMessage page2Message = createMessage(30, groupRoom, user, "페이지2 메시지", + LocalDateTime.of(2026, 4, 11, 13, 0)); + + given(chatRoomRepository.findById(groupRoom.getId())).willReturn(Optional.of(groupRoom)); + given(userRepository.getById(userId)).willReturn(user); + given(chatMessageRepository.findByIdWithChatRoom(50)).willReturn(Optional.of(targetMessage)); + given(chatMessageRepository.countNewerMessagesByChatRoomId( + groupRoom.getId(), 50, targetMessage.getCreatedAt(), null)) + .willReturn(25L); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(groupRoom.getId(), userId)).willReturn( + Optional.of(groupMember)); + given(chatRoomMemberRepository.findByChatRoomId(groupRoom.getId())).willReturn(List.of(groupMember)); + given(chatMessageRepository.countByChatRoomId(groupRoom.getId(), null)).willReturn(100L); + given(chatMessageRepository.findByChatRoomId(eq(groupRoom.getId()), nullable(LocalDateTime.class), + eq(PageRequest.of(1, 20)))) // page=2이므로 offset=20 + .willReturn(new PageImpl<>(List.of(page2Message, targetMessage), PageRequest.of(1, 20), 100L)); + + // when — page=1을 보내도 서버가 page=2로 덮어씀 + ChatMessagePageResponse response = chatService.getMessages(userId, groupRoom.getId(), 1, 20, 50); + + // then + assertThat(response.currentPage()).isEqualTo(2); + assertThat(response.messages().stream().anyMatch(m -> m.messageId().equals(50))).isTrue(); + verify(chatMessageRepository).countNewerMessagesByChatRoomId( + groupRoom.getId(), 50, targetMessage.getCreatedAt(), null); + } + + @Test + @DisplayName("getMessages는 visibleMessageFrom 범위 밖 messageId에 대해 NOT_FOUND_CHAT_ROOM을 던진다") + void getMessagesWithMessageIdThrowsWhenMessageBeforeVisibleMessageFrom() { + // given + Integer userId = 10; + User user = createUser(userId, "사용자", UserRole.USER); + ChatRoom directRoom = createRoom(1, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember member = createRoomMember(directRoom, user, false, LocalDateTime.of(2026, 4, 11, 10, 0)); + // 사용자가 나갔다가 돌아옴 → visibleMessageFrom이 설정됨 + markMemberLeft(member, LocalDateTime.of(2026, 4, 11, 12, 0)); + + // 타겟 메시지가 visibleMessageFrom(12:00)보다 이전(10:30)에 작성됨 + User partner = createUser(20, "상대", UserRole.USER); + ChatMessage oldMessage = createMessage(50, directRoom, partner, "오래된 메시지", + LocalDateTime.of(2026, 4, 11, 10, 30)); + + given(chatRoomRepository.findById(directRoom.getId())).willReturn(Optional.of(directRoom)); + given(userRepository.getById(userId)).willReturn(user); + given(chatMessageRepository.findByIdWithChatRoom(50)).willReturn(Optional.of(oldMessage)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(directRoom.getId(), userId)) + .willReturn(Optional.of(member)); + + // when & then + assertErrorCode( + () -> chatService.getMessages(userId, directRoom.getId(), 1, 20, 50), + NOT_FOUND_CHAT_ROOM + ); + } + + @Test + @DisplayName("getMessages는 messageId가 최신 메시지면 page=1을 계산한다") + void getMessagesWithMessageIdReturnsPage1ForNewestMessage() { + // given + Integer userId = 10; + User user = createUser(userId, "사용자", UserRole.USER); + ChatRoom groupRoom = createRoom(1, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember groupMember = createRoomMember(groupRoom, user, false, LocalDateTime.of(2026, 4, 11, 10, 0)); + + ChatMessage newestMessage = createMessage(100, groupRoom, user, "최신 메시지", + LocalDateTime.of(2026, 4, 11, 15, 0)); + + given(chatRoomRepository.findById(groupRoom.getId())).willReturn(Optional.of(groupRoom)); + given(userRepository.getById(userId)).willReturn(user); + given(chatMessageRepository.findByIdWithChatRoom(100)).willReturn(Optional.of(newestMessage)); + // 최신 메시지보다 더 최신인 메시지가 0개 → page = 0/20 + 1 = 1 + given(chatMessageRepository.countNewerMessagesByChatRoomId( + groupRoom.getId(), 100, newestMessage.getCreatedAt(), null)) + .willReturn(0L); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(groupRoom.getId(), userId)).willReturn( + Optional.of(groupMember)); + given(chatRoomMemberRepository.findByChatRoomId(groupRoom.getId())).willReturn(List.of(groupMember)); + given(chatMessageRepository.countByChatRoomId(groupRoom.getId(), null)).willReturn(50L); + given(chatMessageRepository.findByChatRoomId(eq(groupRoom.getId()), nullable(LocalDateTime.class), + eq(PageRequest.of(0, 20)))) + .willReturn(new PageImpl<>(List.of(newestMessage), PageRequest.of(0, 20), 50L)); + + // when + ChatMessagePageResponse response = chatService.getMessages(userId, groupRoom.getId(), 1, 20, 100); + + // then + assertThat(response.currentPage()).isEqualTo(1); + assertThat(response.messages().stream().anyMatch(m -> m.messageId().equals(100))).isTrue(); + } + + @Test + @DisplayName("getMessages는 비회원이 messageId로 조회하면 NOT_FOUND_CHAT_ROOM을 던진다 (오라클 방지)") + void getMessagesWithMessageIdRejectsNonMemberWithNotFound() { + // given + Integer nonMemberId = 99; + User nonMember = createUser(nonMemberId, "비회원", UserRole.USER); + ChatRoom groupRoom = createRoom(1, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + // messageId=50에 해당하는 메시지가 존재하더라도 비회원이므로 404 + given(chatRoomRepository.findById(groupRoom.getId())).willReturn(Optional.of(groupRoom)); + given(userRepository.getById(nonMemberId)).willReturn(nonMember); + // 비회원은 멤버십이 없음 + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(groupRoom.getId(), nonMemberId)) + .willReturn(Optional.empty()); + + // when & then — 유효한 messageId여도 접근 권한 없음과 동일한 404 + assertErrorCode( + () -> chatService.getMessages(nonMemberId, groupRoom.getId(), 1, 20, 50), + NOT_FOUND_CHAT_ROOM + ); + // messageId 조회 자체가 실행되지 않아야 함 + verify(chatMessageRepository, never()).findByIdWithChatRoom(any()); + } + + @Test + @DisplayName("getMessages는 visibleMessageFrom과 동일 시각의 messageId를 거부한다 (경계 조건)") + void getMessagesWithMessageIdRejectsMessageAtExactVisibleMessageFromBoundary() { + // given + Integer userId = 10; + User user = createUser(userId, "사용자", UserRole.USER); + ChatRoom directRoom = createRoom(1, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember member = createRoomMember(directRoom, user, false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + LocalDateTime leftAt = LocalDateTime.of(2026, 4, 11, 12, 0); + markMemberLeft(member, leftAt); + + // 메시지가 visibleMessageFrom과 정확히 같은 시각 + User partner = createUser(20, "상대", UserRole.USER); + ChatMessage boundaryMessage = createMessage(50, directRoom, partner, "경계 메시지", leftAt); + + given(chatRoomRepository.findById(directRoom.getId())).willReturn(Optional.of(directRoom)); + given(userRepository.getById(userId)).willReturn(user); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(directRoom.getId(), userId)) + .willReturn(Optional.of(member)); + given(chatMessageRepository.findByIdWithChatRoom(50)).willReturn(Optional.of(boundaryMessage)); + + // when & then + assertErrorCode( + () -> chatService.getMessages(userId, directRoom.getId(), 1, 20, 50), + NOT_FOUND_CHAT_ROOM + ); + } + + private User createUser(Integer id, String name, UserRole role) { + return UserFixture.createUserWithId(UniversityFixture.createWithId(1), id, name, + "2024" + String.format("%04d", id), role); + } + + private ChatRoom createRoom(Integer id, ChatType type, LocalDateTime createdAt) { + ChatRoom room = switch (type) { + case DIRECT -> ChatRoom.directOf(); + case GROUP -> ChatRoom.groupOf(); + case CLUB_GROUP -> ChatRoom.clubGroupOf(ClubFixture.createWithId(UniversityFixture.createWithId(1), 77)); + default -> throw new IllegalArgumentException("Unsupported ChatType: " + type); + }; + ReflectionTestUtils.setField(room, "id", id); + ReflectionTestUtils.setField(room, "createdAt", createdAt); + return room; + } + + private ChatRoom createClubRoom(Integer id, LocalDateTime createdAt) { + Club club = ClubFixture.createWithId(UniversityFixture.createWithId(1), 77, "BCSD"); + ChatRoom room = ChatRoom.clubGroupOf(club); + ReflectionTestUtils.setField(room, "id", id); + ReflectionTestUtils.setField(room, "createdAt", createdAt); + return room; + } + + private ChatRoomMember createRoomMember(ChatRoom room, User user, boolean isOwner, LocalDateTime lastReadAt) { + ChatRoomMember member = + isOwner ? ChatRoomMember.ofOwner(room, user, lastReadAt) : ChatRoomMember.of(room, user, lastReadAt); + ReflectionTestUtils.setField(member, "createdAt", lastReadAt); + return member; + } + + private ChatMessage createMessage(Integer id, ChatRoom room, User sender, String content, LocalDateTime createdAt) { + ChatMessage message = ChatMessage.of(room, sender, content); + ReflectionTestUtils.setField(message, "id", id); + ReflectionTestUtils.setField(message, "createdAt", createdAt); + return message; + } + + private void markMemberLeft(ChatRoomMember member, LocalDateTime leftAt) { + ReflectionTestUtils.setField(member, "leftAt", leftAt); + ReflectionTestUtils.setField(member, "visibleMessageFrom", leftAt); + } + + private void assertErrorCode(ThrowingCallable callable, ApiResponseCode errorCode) { + assertThatThrownBy(callable) + .isInstanceOf(CustomException.class) + .satisfies(exception -> assertThat(((CustomException)exception).getErrorCode()).isEqualTo(errorCode)); + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/club/service/ClubApplicationServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubApplicationServiceTest.java new file mode 100644 index 000000000..bef0918d2 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubApplicationServiceTest.java @@ -0,0 +1,1268 @@ +package gg.agit.konect.unit.domain.club.service; + +import static gg.agit.konect.global.code.ApiResponseCode.ALREADY_APPLIED_CLUB; +import static gg.agit.konect.global.code.ApiResponseCode.ALREADY_CLUB_MEMBER; +import static gg.agit.konect.global.code.ApiResponseCode.DUPLICATE_CLUB_APPLY_QUESTION; +import static gg.agit.konect.global.code.ApiResponseCode.FEE_PAYMENT_IMAGE_REQUIRED; +import static gg.agit.konect.global.code.ApiResponseCode.ALREADY_PROCESSED_CLUB_APPLY; +import static gg.agit.konect.global.code.ApiResponseCode.INVALID_REQUEST_BODY; +import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_CLUB_APPLY_QUESTION; +import static gg.agit.konect.global.code.ApiResponseCode.REQUIRED_CLUB_APPLY_ANSWER_MISSING; +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.argThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.test.util.ReflectionTestUtils; + +import gg.agit.konect.domain.bank.model.Bank; +import gg.agit.konect.domain.bank.repository.BankRepository; +import gg.agit.konect.domain.chat.service.ChatRoomMembershipService; +import gg.agit.konect.domain.club.dto.ClubApplicationAnswersResponse; +import gg.agit.konect.domain.club.dto.ClubApplicationCondition; +import gg.agit.konect.domain.club.dto.ClubApplicationsResponse; +import gg.agit.konect.domain.club.dto.ClubAppliedClubsResponse; +import gg.agit.konect.domain.club.dto.ClubApplyQuestionsReplaceRequest; +import gg.agit.konect.domain.club.dto.ClubApplyQuestionsResponse; +import gg.agit.konect.domain.club.dto.ClubApplyRequest; +import gg.agit.konect.domain.club.dto.ClubFeeInfoReplaceRequest; +import gg.agit.konect.domain.club.dto.ClubFeeInfoResponse; +import gg.agit.konect.domain.club.dto.ClubMemberApplicationAnswersResponse; +import gg.agit.konect.domain.club.enums.ClubApplyStatus; +import gg.agit.konect.domain.club.event.ClubApplicationApprovedEvent; +import gg.agit.konect.domain.club.event.ClubApplicationRejectedEvent; +import gg.agit.konect.domain.club.event.ClubApplicationSubmittedEvent; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.model.ClubApply; +import gg.agit.konect.domain.club.model.ClubApplyAnswer; +import gg.agit.konect.domain.club.model.ClubApplyQuestion; +import gg.agit.konect.domain.club.model.ClubMember; +import gg.agit.konect.domain.club.model.ClubRecruitment; +import gg.agit.konect.domain.club.repository.ClubApplyAnswerRepository; +import gg.agit.konect.domain.club.repository.ClubApplyQueryRepository; +import gg.agit.konect.domain.club.repository.ClubApplyQuestionRepository; +import gg.agit.konect.domain.club.repository.ClubApplyRepository; +import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.club.repository.ClubRecruitmentRepository; +import gg.agit.konect.domain.club.repository.ClubRepository; +import gg.agit.konect.domain.club.service.ClubApplicationService; +import gg.agit.konect.domain.club.service.ClubPermissionValidator; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.support.ServiceTestSupport; +import gg.agit.konect.support.fixture.ClubFixture; +import gg.agit.konect.support.fixture.ClubMemberFixture; +import gg.agit.konect.support.fixture.UniversityFixture; +import gg.agit.konect.support.fixture.UserFixture; + +class ClubApplicationServiceTest extends ServiceTestSupport { + + @Mock + private ClubRepository clubRepository; + + @Mock + private ClubRecruitmentRepository clubRecruitmentRepository; + + @Mock + private ClubApplyRepository clubApplyRepository; + + @Mock + private ClubApplyQuestionRepository clubApplyQuestionRepository; + + @Mock + private ClubApplyAnswerRepository clubApplyAnswerRepository; + + @Mock + private ClubApplyQueryRepository clubApplyQueryRepository; + + @Mock + private ClubMemberRepository clubMemberRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private BankRepository bankRepository; + + @Mock + private ClubPermissionValidator clubPermissionValidator; + + @Mock + private ApplicationEventPublisher applicationEventPublisher; + + @Mock + private ChatRoomMembershipService chatRoomMembershipService; + + @InjectMocks + private ClubApplicationService clubApplicationService; + + @Test + @DisplayName("applyClub은 이미 동아리 멤버인 사용자의 지원을 거부한다") + void applyClubRejectsAlreadyMember() { + // given + Club club = createClub(1); + User user = createUser(10, "2021136001", "지원자"); + ClubApplyRequest request = new ClubApplyRequest(List.of(), null); + given(clubRepository.getById(1)).willReturn(club); + given(userRepository.getById(10)).willReturn(user); + given(clubMemberRepository.existsByClubIdAndUserId(1, 10)).willReturn(true); + + // when & then + assertErrorCode(() -> clubApplicationService.applyClub(1, 10, request), ALREADY_CLUB_MEMBER); + verify(clubApplyRepository, never()).save(any()); + } + + @Test + @DisplayName("applyClub은 이미 pending 지원서가 있으면 중복 지원을 거부한다") + void applyClubRejectsAlreadyApplied() { + // given + Club club = createClub(1); + User user = createUser(10, "2021136001", "지원자"); + ClubApplyRequest request = new ClubApplyRequest(List.of(), null); + given(clubRepository.getById(1)).willReturn(club); + given(userRepository.getById(10)).willReturn(user); + given(clubMemberRepository.existsByClubIdAndUserId(1, 10)).willReturn(false); + given(clubApplyRepository.existsPendingByClubIdAndUserId(1, 10)).willReturn(true); + + // when & then + assertErrorCode(() -> clubApplicationService.applyClub(1, 10, request), ALREADY_APPLIED_CLUB); + verify(clubApplyRepository, never()).save(any()); + } + + @Test + @DisplayName("applyClub은 회비 필수 동아리에서 납부 이미지가 없으면 실패한다") + void applyClubRequiresFeePaymentImage() { + // given + Club club = createFeeRequiredClub(1); + User user = createUser(10, "2021136001", "지원자"); + ClubApplyRequest request = new ClubApplyRequest(List.of(), null); + given(clubRepository.getById(1)).willReturn(club); + given(userRepository.getById(10)).willReturn(user); + given(clubMemberRepository.existsByClubIdAndUserId(1, 10)).willReturn(false); + given(clubApplyRepository.existsPendingByClubIdAndUserId(1, 10)).willReturn(false); + + // when & then + assertErrorCode(() -> clubApplicationService.applyClub(1, 10, request), FEE_PAYMENT_IMAGE_REQUIRED); + verify(clubApplyRepository, never()).save(any()); + } + + @Test + @DisplayName("applyClub은 답변을 저장하고 운영진이 있으면 제출 이벤트를 발행한다") + void applyClubSavesAnswersAndPublishesSubmittedEvent() { + // given + Club club = createClubWithFeeInfo(1, "국민은행"); + User applicant = createUser(10, "2021136001", "지원자"); + User managerUser = createUser(20, "2021136002", "운영진"); + ClubMember manager = ClubMemberFixture.createManager(club, managerUser); + ClubApplyQuestion question = createQuestion(club, 100, "지원 동기", true, 1, at(2026, 4, 1, 12, 0)); + ClubApplyRequest request = new ClubApplyRequest( + List.of(new ClubApplyRequest.InnerClubQuestionAnswer(100, "백엔드를 공부하고 싶습니다.")), + "https://example.com/payment.png" + ); + Bank bank = org.mockito.Mockito.mock(Bank.class); + ClubApply savedApply = ClubApply.of(club, applicant, request.feePaymentImageUrl()); + setId(savedApply, 300); + + given(clubRepository.getById(1)).willReturn(club); + given(userRepository.getById(10)).willReturn(applicant); + given(clubMemberRepository.existsByClubIdAndUserId(1, 10)).willReturn(false); + given(clubApplyRepository.existsPendingByClubIdAndUserId(1, 10)).willReturn(false); + given(clubApplyQuestionRepository.findAllByClubIdOrderByDisplayOrderAsc(1)).willReturn(List.of(question)); + given(clubApplyRepository.save(any(ClubApply.class))).willReturn(savedApply); + given(clubMemberRepository.findAllByClubIdAndPositionIn(1, + gg.agit.konect.domain.club.enums.ClubPosition.MANAGERS)) + .willReturn(List.of(manager)); + given(bankRepository.getByName("국민은행")).willReturn(bank); + given(bank.getId()).willReturn(7); + + // when + ClubFeeInfoResponse response = clubApplicationService.applyClub(1, 10, request); + + // then + verify(clubApplyAnswerRepository).saveAll(argThat(answers -> { + List savedAnswers = (List)answers; + return savedAnswers.size() == 1 + && savedAnswers.get(0).getQuestion().getId().equals(100) + && savedAnswers.get(0).getAnswer().equals("백엔드를 공부하고 싶습니다."); + })); + verify(applicationEventPublisher).publishEvent(ClubApplicationSubmittedEvent.of( + List.of(20), + 300, + 1, + club.getName(), + applicant.getName() + )); + assertThat(response.bankId()).isEqualTo(7); + assertThat(response.bankName()).isEqualTo("국민은행"); + assertThat(response.accountNumber()).isEqualTo("123-456-7890"); + } + + @Test + @DisplayName("approveClubApplication은 멤버 저장, 채팅 멤버십 추가, 승인 이벤트 발행을 수행한다") + void approveClubApplicationAddsMembershipAndPublishesEvent() { + // given + Club club = createClub(1); + User applicant = createUser(10, "2021136001", "지원자"); + ClubApply clubApply = ClubApply.of(club, applicant, null); + ClubMember savedMember = ClubMemberFixture.createMember(club, applicant); + + given(clubRepository.getById(1)).willReturn(club); + given(clubApplyRepository.getByIdAndClubIdForUpdate(100, 1)).willReturn(clubApply); + given(clubMemberRepository.existsByClubIdAndUserId(1, 10)).willReturn(false); + given(clubMemberRepository.save(any(ClubMember.class))).willReturn(savedMember); + + // when + clubApplicationService.approveClubApplication(1, 100, 99); + + // then + assertThat(clubApply.getStatus()).isEqualTo(ClubApplyStatus.APPROVED); + verify(clubPermissionValidator).validateManagerAccess(1, 99); + verify(chatRoomMembershipService).addClubMember(savedMember); + verify(applicationEventPublisher).publishEvent(ClubApplicationApprovedEvent.of(10, 1, club.getName())); + } + + @Test + @DisplayName("approveClubApplication은 이미 멤버인 사용자를 다시 승인하지 않는다") + void approveClubApplicationRejectsExistingMember() { + // given + Club club = createClub(1); + User applicant = createUser(10, "2021136001", "지원자"); + ClubApply clubApply = ClubApply.of(club, applicant, null); + + given(clubRepository.getById(1)).willReturn(club); + given(clubApplyRepository.getByIdAndClubIdForUpdate(100, 1)).willReturn(clubApply); + given(clubMemberRepository.existsByClubIdAndUserId(1, 10)).willReturn(true); + + // when & then + assertErrorCode(() -> clubApplicationService.approveClubApplication(1, 100, 99), ALREADY_CLUB_MEMBER); + assertThat(clubApply.getStatus()).isEqualTo(ClubApplyStatus.PENDING); + verify(chatRoomMembershipService, never()).addClubMember(any()); + verify(applicationEventPublisher, never()).publishEvent(any()); + } + + @Test + @DisplayName("rejectClubApplication은 상태를 거절로 바꾸고 거절 이벤트를 발행한다") + void rejectClubApplicationChangesStatusAndPublishesEvent() { + // given + Club club = createClub(1); + User applicant = createUser(10, "2021136001", "지원자"); + ClubApply clubApply = ClubApply.of(club, applicant, null); + + given(clubRepository.getById(1)).willReturn(club); + given(clubApplyRepository.getByIdAndClubIdForUpdate(100, 1)).willReturn(clubApply); + + // when + clubApplicationService.rejectClubApplication(1, 100, 99); + + // then + assertThat(clubApply.getStatus()).isEqualTo(ClubApplyStatus.REJECTED); + verify(clubPermissionValidator).validateManagerAccess(1, 99); + verify(applicationEventPublisher).publishEvent(ClubApplicationRejectedEvent.of(10, 1, club.getName())); + } + + @Test + @DisplayName("replaceApplyQuestions는 수정된 질문을 soft delete하고 새 질문을 생성하며 빠진 질문도 soft delete한다") + void replaceApplyQuestionsSoftDeletesAndCreatesQuestions() { + // given + Club club = createClub(1); + ClubApplyQuestion unchangedQuestion = createQuestion(club, 1, "지원 동기", true, 1, at(2026, 4, 1, 10, 0)); + ClubApplyQuestion changedQuestion = createQuestion(club, 2, "관심 분야", false, 2, at(2026, 4, 1, 10, 0)); + ClubApplyQuestion removedQuestion = createQuestion(club, 3, "자기소개", true, 3, at(2026, 4, 1, 10, 0)); + ClubApplyQuestion createdQuestion = createQuestion(club, 4, "새 질문", true, 2, at(2026, 4, 2, 10, 0)); + ClubApplyQuestionsReplaceRequest request = new ClubApplyQuestionsReplaceRequest(List.of( + new ClubApplyQuestionsReplaceRequest.ApplyQuestionRequest(1, "지원 동기", true), + new ClubApplyQuestionsReplaceRequest.ApplyQuestionRequest(2, "관심 기술", false), + new ClubApplyQuestionsReplaceRequest.ApplyQuestionRequest(null, "새 질문", true) + )); + List existingQuestions = List.of(unchangedQuestion, changedQuestion, removedQuestion); + ArgumentCaptor> questionsCaptor = ArgumentCaptor.forClass(List.class); + + given(clubRepository.getById(1)).willReturn(club); + given(clubApplyQuestionRepository.findAllByClubIdOrderByDisplayOrderAsc(1)) + .willReturn(existingQuestions) + .willReturn(List.of(unchangedQuestion, createdQuestion)); + + // when + var response = clubApplicationService.replaceApplyQuestions(1, 99, request); + + // then + verify(clubPermissionValidator).validateManagerAccess(1, 99); + verify(clubApplyQuestionRepository).saveAll(questionsCaptor.capture()); + List createdQuestions = questionsCaptor.getValue(); + assertThat(createdQuestions).hasSize(2); + assertThat(createdQuestions) + .extracting(ClubApplyQuestion::getQuestion) + .containsExactly("관심 기술", "새 질문"); + assertThat(unchangedQuestion.getDisplayOrder()).isEqualTo(1); + assertThat(changedQuestion.getDeletedAt()).isNotNull(); + assertThat(removedQuestion.getDeletedAt()).isNotNull(); + assertThat(response.questions()).hasSize(2); + } + + @Test + @DisplayName("replaceApplyQuestions는 중복 questionId를 거부한다") + void replaceApplyQuestionsRejectsDuplicateQuestionIds() { + // given + Club club = createClub(1); + ClubApplyQuestion existingQuestion = createQuestion(club, 1, "지원 동기", true, 1, at(2026, 4, 1, 10, 0)); + ClubApplyQuestionsReplaceRequest request = new ClubApplyQuestionsReplaceRequest(List.of( + new ClubApplyQuestionsReplaceRequest.ApplyQuestionRequest(1, "지원 동기", true), + new ClubApplyQuestionsReplaceRequest.ApplyQuestionRequest(1, "지원 동기 수정", true) + )); + + given(clubRepository.getById(1)).willReturn(club); + given(clubApplyQuestionRepository.findAllByClubIdOrderByDisplayOrderAsc(1)).willReturn( + List.of(existingQuestion)); + + // when & then + assertErrorCode(() -> clubApplicationService.replaceApplyQuestions(1, 99, request), + DUPLICATE_CLUB_APPLY_QUESTION); + verify(clubApplyQuestionRepository, never()).saveAll(any()); + } + + @Test + @DisplayName("getApprovedMemberApplicationAnswersList는 승인 지원서가 없으면 빈 응답을 즉시 반환한다") + void getApprovedMemberApplicationAnswersListReturnsEmptyImmediately() { + // given + Page emptyPage = new PageImpl<>(List.of()); + ClubApplicationCondition condition = new ClubApplicationCondition(1, 10, null, null); + given(clubRepository.getById(1)).willReturn(createClub(1)); + given(clubApplyQueryRepository.findApprovedMemberApplicationsByClubId(1, condition)).willReturn(emptyPage); + + // when + ClubMemberApplicationAnswersResponse response = + clubApplicationService.getApprovedMemberApplicationAnswersList(1, 99, condition); + + // then + assertThat(response.applications()).isEmpty(); + verify(clubPermissionValidator).validateManagerAccess(1, 99); + verify(clubApplyAnswerRepository, never()).findAllByApplyIdsWithQuestion(any()); + verify(clubApplyQuestionRepository, never()).findAllCandidatesVisibleBetweenApplyTimes(any(), any(), any()); + } + + @Test + @DisplayName("getApprovedMemberApplicationAnswersList는 지원 시점에 보이던 질문만 각 지원서에 포함한다") + void getApprovedMemberApplicationAnswersListUsesQuestionVisibilityAtApplyTime() { + // given + Club club = createClub(1); + User firstUser = createUser(10, "2021136001", "첫번째"); + User secondUser = createUser(20, "2021136002", "두번째"); + LocalDateTime firstAppliedAt = at(2026, 4, 2, 10, 0); + LocalDateTime secondAppliedAt = at(2026, 4, 4, 10, 0); + + ClubApply firstApply = createApprovedApply(club, firstUser, 100, firstAppliedAt); + ClubApply secondApply = createApprovedApply(club, secondUser, 200, secondAppliedAt); + Page page = new PageImpl<>(List.of(firstApply, secondApply)); + ClubApplicationCondition condition = new ClubApplicationCondition(1, 10, null, null); + + ClubApplyQuestion oldQuestion = createQuestion(club, 1, "공통 질문", true, 1, at(2026, 4, 1, 9, 0)); + ClubApplyQuestion deletedQuestion = createQuestion(club, 2, "이전 질문", false, 2, at(2026, 4, 1, 9, 0)); + deletedQuestion.softDelete(at(2026, 4, 3, 0, 0)); + ClubApplyQuestion newQuestion = createQuestion(club, 3, "신규 질문", true, 3, at(2026, 4, 3, 12, 0)); + + ClubApplyAnswer firstAnswer1 = ClubApplyAnswer.of(firstApply, oldQuestion, "첫번째 공통 답변"); + ClubApplyAnswer firstAnswer2 = ClubApplyAnswer.of(firstApply, deletedQuestion, "첫번째 이전 답변"); + ClubApplyAnswer secondAnswer1 = ClubApplyAnswer.of(secondApply, oldQuestion, "두번째 공통 답변"); + ClubApplyAnswer secondAnswer2 = ClubApplyAnswer.of(secondApply, newQuestion, "두번째 신규 답변"); + + given(clubRepository.getById(1)).willReturn(club); + given(clubApplyQueryRepository.findApprovedMemberApplicationsByClubId(1, condition)).willReturn(page); + given(clubApplyAnswerRepository.findAllByApplyIdsWithQuestion(List.of(100, 200))) + .willReturn(List.of(firstAnswer1, firstAnswer2, secondAnswer1, secondAnswer2)); + given(clubApplyQuestionRepository.findAllCandidatesVisibleBetweenApplyTimes(1, firstAppliedAt, secondAppliedAt)) + .willReturn(List.of(oldQuestion, deletedQuestion, newQuestion)); + + // when + ClubMemberApplicationAnswersResponse response = + clubApplicationService.getApprovedMemberApplicationAnswersList(1, 99, condition); + + // then + assertThat(response.applications()).hasSize(2); + + ClubApplicationAnswersResponse firstResponse = response.applications().get(0); + assertThat(firstResponse.applicationId()).isEqualTo(100); + assertThat(firstResponse.answers()) + .extracting(ClubApplicationAnswersResponse.ClubApplicationAnswerResponse::question) + .containsExactly("공통 질문", "이전 질문"); + + ClubApplicationAnswersResponse secondResponse = response.applications().get(1); + assertThat(secondResponse.applicationId()).isEqualTo(200); + assertThat(secondResponse.answers()) + .extracting(ClubApplicationAnswersResponse.ClubApplicationAnswerResponse::question) + .containsExactly("공통 질문", "신규 질문"); + } + + @Test + @DisplayName("applyClub은 필수 질문에 답변이 누락되면 실패한다") + void applyClubRejectsMissingRequiredAnswer() { + // given + Club club = createClub(1); + User user = createUser(10, "2021136001", "지원자"); + ClubApplyQuestion requiredQuestion = createQuestion(club, 100, "지원 동기", true, 1, at(2026, 4, 1, 12, 0)); + ClubApplyRequest request = new ClubApplyRequest(List.of(), null); + + given(clubRepository.getById(1)).willReturn(club); + given(userRepository.getById(10)).willReturn(user); + given(clubMemberRepository.existsByClubIdAndUserId(1, 10)).willReturn(false); + given(clubApplyRepository.existsPendingByClubIdAndUserId(1, 10)).willReturn(false); + given(clubApplyQuestionRepository.findAllByClubIdOrderByDisplayOrderAsc(1)).willReturn( + List.of(requiredQuestion)); + + // when & then + assertErrorCode(() -> clubApplicationService.applyClub(1, 10, request), REQUIRED_CLUB_APPLY_ANSWER_MISSING); + verify(clubApplyRepository, never()).save(any()); + } + + @Test + @DisplayName("applyClub은 존재하지 않는 질문 ID에 답변하면 실패한다") + void applyClubRejectsAnswerToNonExistentQuestion() { + // given + Club club = createClub(1); + User user = createUser(10, "2021136001", "지원자"); + ClubApplyRequest request = new ClubApplyRequest( + List.of(new ClubApplyRequest.InnerClubQuestionAnswer(999, "답변")), + null + ); + + given(clubRepository.getById(1)).willReturn(club); + given(userRepository.getById(10)).willReturn(user); + given(clubMemberRepository.existsByClubIdAndUserId(1, 10)).willReturn(false); + given(clubApplyRepository.existsPendingByClubIdAndUserId(1, 10)).willReturn(false); + given(clubApplyQuestionRepository.findAllByClubIdOrderByDisplayOrderAsc(1)).willReturn(List.of()); + + // when & then + assertErrorCode(() -> clubApplicationService.applyClub(1, 10, request), NOT_FOUND_CLUB_APPLY_QUESTION); + verify(clubApplyRepository, never()).save(any()); + } + + @Test + @DisplayName("applyClub은 답변에 중복 질문 ID가 있으면 실패한다") + void applyClubRejectsDuplicateAnswerQuestionIds() { + // given + Club club = createClub(1); + User user = createUser(10, "2021136001", "지원자"); + ClubApplyRequest request = new ClubApplyRequest( + List.of( + new ClubApplyRequest.InnerClubQuestionAnswer(100, "첫 답변"), + new ClubApplyRequest.InnerClubQuestionAnswer(100, "중복 답변") + ), + null + ); + + given(clubRepository.getById(1)).willReturn(club); + given(userRepository.getById(10)).willReturn(user); + given(clubMemberRepository.existsByClubIdAndUserId(1, 10)).willReturn(false); + given(clubApplyRepository.existsPendingByClubIdAndUserId(1, 10)).willReturn(false); + + // when & then + assertErrorCode(() -> clubApplicationService.applyClub(1, 10, request), DUPLICATE_CLUB_APPLY_QUESTION); + verify(clubApplyRepository, never()).save(any()); + } + + @Test + @DisplayName("applyClub은 운영진이 없으면 제출 이벤트를 발행하지 않는다") + void applyClubDoesNotPublishEventWhenNoManagers() { + // given + Club club = createClub(1); + User user = createUser(10, "2021136001", "지원자"); + ClubApply savedApply = ClubApply.of(club, user, null); + setId(savedApply, 300); + ClubApplyRequest request = new ClubApplyRequest(List.of(), null); + + given(clubRepository.getById(1)).willReturn(club); + given(userRepository.getById(10)).willReturn(user); + given(clubMemberRepository.existsByClubIdAndUserId(1, 10)).willReturn(false); + given(clubApplyRepository.existsPendingByClubIdAndUserId(1, 10)).willReturn(false); + given(clubApplyQuestionRepository.findAllByClubIdOrderByDisplayOrderAsc(1)).willReturn(List.of()); + given(clubApplyRepository.save(any(ClubApply.class))).willReturn(savedApply); + given(clubMemberRepository.findAllByClubIdAndPositionIn(1, + gg.agit.konect.domain.club.enums.ClubPosition.MANAGERS)) + .willReturn(List.of()); + + // when + clubApplicationService.applyClub(1, 10, request); + + // then + verify(applicationEventPublisher, never()).publishEvent(any()); + verify(clubApplyAnswerRepository, never()).saveAll(any()); + } + + @Test + @DisplayName("applyClub은 회비 불필요 동아리에서 이미지 없이도 성공한다") + void applyClubSucceedsWithoutFeeImage() { + // given + Club club = createClub(1); + User user = createUser(10, "2021136001", "지원자"); + ClubApply savedApply = ClubApply.of(club, user, null); + setId(savedApply, 300); + ClubApplyRequest request = new ClubApplyRequest(List.of(), null); + + given(clubRepository.getById(1)).willReturn(club); + given(userRepository.getById(10)).willReturn(user); + given(clubMemberRepository.existsByClubIdAndUserId(1, 10)).willReturn(false); + given(clubApplyRepository.existsPendingByClubIdAndUserId(1, 10)).willReturn(false); + given(clubApplyQuestionRepository.findAllByClubIdOrderByDisplayOrderAsc(1)).willReturn(List.of()); + given(clubApplyRepository.save(any(ClubApply.class))).willReturn(savedApply); + given(clubMemberRepository.findAllByClubIdAndPositionIn(1, + gg.agit.konect.domain.club.enums.ClubPosition.MANAGERS)) + .willReturn(List.of()); + + // when + ClubFeeInfoResponse response = clubApplicationService.applyClub(1, 10, request); + + // then + assertThat(response.bankId()).isNull(); + assertThat(response.bankName()).isNull(); + assertThat(response.amount()).isNull(); + } + + @Test + @DisplayName("applyClub은 선택 질문에 빈 답변을 허용한다") + void applyClubAllowsEmptyAnswerForOptionalQuestion() { + // given + Club club = createClub(1); + User user = createUser(10, "2021136001", "지원자"); + ClubApplyQuestion optionalQuestion = createQuestion(club, 100, "관심 분야", false, 1, at(2026, 4, 1, 12, 0)); + ClubApply savedApply = ClubApply.of(club, user, null); + setId(savedApply, 300); + ClubApplyRequest request = new ClubApplyRequest( + List.of(new ClubApplyRequest.InnerClubQuestionAnswer(100, "")), + null + ); + + given(clubRepository.getById(1)).willReturn(club); + given(userRepository.getById(10)).willReturn(user); + given(clubMemberRepository.existsByClubIdAndUserId(1, 10)).willReturn(false); + given(clubApplyRepository.existsPendingByClubIdAndUserId(1, 10)).willReturn(false); + given(clubApplyQuestionRepository.findAllByClubIdOrderByDisplayOrderAsc(1)).willReturn( + List.of(optionalQuestion)); + given(clubApplyRepository.save(any(ClubApply.class))).willReturn(savedApply); + given(clubMemberRepository.findAllByClubIdAndPositionIn(1, + gg.agit.konect.domain.club.enums.ClubPosition.MANAGERS)) + .willReturn(List.of()); + + // when + clubApplicationService.applyClub(1, 10, request); + + // then + verify(clubApplyAnswerRepository, never()).saveAll(any()); + } + + @Test + @DisplayName("replaceApplyQuestions는 존재하지 않는 질문 ID를 거부한다") + void replaceApplyQuestionsRejectsNonExistentQuestionId() { + // given + Club club = createClub(1); + ClubApplyQuestionsReplaceRequest request = new ClubApplyQuestionsReplaceRequest(List.of( + new ClubApplyQuestionsReplaceRequest.ApplyQuestionRequest(999, "수정된 질문", true) + )); + + given(clubRepository.getById(1)).willReturn(club); + given(clubApplyQuestionRepository.findAllByClubIdOrderByDisplayOrderAsc(1)).willReturn(List.of()); + + // when & then + assertErrorCode(() -> clubApplicationService.replaceApplyQuestions(1, 99, request), + NOT_FOUND_CLUB_APPLY_QUESTION); + verify(clubApplyQuestionRepository, never()).saveAll(any()); + } + + @Test + @DisplayName("replaceApplyQuestions는 모든 질문이 새로 생성되는 경우 정상 동작한다") + void replaceApplyQuestionsCreatesAllNewQuestions() { + // given + Club club = createClub(1); + ClubApplyQuestion newQuestion1 = createQuestion(club, 10, "새 질문 1", true, 1, at(2026, 4, 2, 10, 0)); + ClubApplyQuestion newQuestion2 = createQuestion(club, 11, "새 질문 2", false, 2, at(2026, 4, 2, 10, 0)); + ClubApplyQuestionsReplaceRequest request = new ClubApplyQuestionsReplaceRequest(List.of( + new ClubApplyQuestionsReplaceRequest.ApplyQuestionRequest(null, "새 질문 1", true), + new ClubApplyQuestionsReplaceRequest.ApplyQuestionRequest(null, "새 질문 2", false) + )); + + given(clubRepository.getById(1)).willReturn(club); + given(clubApplyQuestionRepository.findAllByClubIdOrderByDisplayOrderAsc(1)) + .willReturn(List.of()) + .willReturn(List.of(newQuestion1, newQuestion2)); + + // when + ClubApplyQuestionsResponse response = clubApplicationService.replaceApplyQuestions(1, 99, request); + + // then + verify(clubApplyQuestionRepository).saveAll(argThat(list -> ((List)list).size() == 2)); + assertThat(response.questions()).hasSize(2); + } + + @Test + @DisplayName("replaceApplyQuestions는 빈 질문 목록이면 기존 질문을 모두 soft delete한다") + void replaceApplyQuestionsSoftDeletesAllWhenEmptyRequest() { + // given + Club club = createClub(1); + ClubApplyQuestion existingQ1 = createQuestion(club, 1, "질문 1", true, 1, at(2026, 4, 1, 10, 0)); + ClubApplyQuestion existingQ2 = createQuestion(club, 2, "질문 2", false, 2, at(2026, 4, 1, 10, 0)); + ClubApplyQuestionsReplaceRequest request = new ClubApplyQuestionsReplaceRequest(List.of()); + + given(clubRepository.getById(1)).willReturn(club); + given(clubApplyQuestionRepository.findAllByClubIdOrderByDisplayOrderAsc(1)) + .willReturn(List.of(existingQ1, existingQ2)) + .willReturn(List.of()); + + // when + ClubApplyQuestionsResponse response = clubApplicationService.replaceApplyQuestions(1, 99, request); + + // then + assertThat(existingQ1.getDeletedAt()).isNotNull(); + assertThat(existingQ2.getDeletedAt()).isNotNull(); + verify(clubApplyQuestionRepository, never()).saveAll(any()); + assertThat(response.questions()).isEmpty(); + } + + @Test + @DisplayName("getClubApplicationAnswers는 특정 지원서의 답변을 질문과 함께 반환한다") + void getClubApplicationAnswersReturnsAnswersWithQuestions() { + // given + Club club = createClub(1); + User applicant = createUser(10, "2021136001", "지원자"); + LocalDateTime appliedAt = at(2026, 4, 2, 10, 0); + ClubApply clubApply = createPendingApply(club, applicant, 100, appliedAt); + ClubApplyQuestion question = createQuestion(club, 200, "지원 동기", true, 1, at(2026, 4, 1, 9, 0)); + ClubApplyAnswer answer = ClubApplyAnswer.of(clubApply, question, "성장하고 싶습니다."); + + given(clubRepository.getById(1)).willReturn(club); + given(clubApplyRepository.getByIdAndClubId(100, 1)).willReturn(clubApply); + given(clubApplyAnswerRepository.findAllByApplyIdWithQuestion(100)).willReturn(List.of(answer)); + given(clubApplyQuestionRepository.findAllVisibleAtApplyTime(1, appliedAt)).willReturn(List.of(question)); + + // when + ClubApplicationAnswersResponse response = clubApplicationService.getClubApplicationAnswers(1, 100, 99); + + // then + verify(clubPermissionValidator).validateManagerAccess(1, 99); + assertThat(response.applicationId()).isEqualTo(100); + assertThat(response.answers()).hasSize(1); + assertThat(response.answers().get(0).question()).isEqualTo("지원 동기"); + assertThat(response.answers().get(0).answer()).isEqualTo("성장하고 싶습니다."); + } + + @Test + @DisplayName("getApplyQuestions는 삭제되지 않은 질문 목록을 반환한다") + void getApplyQuestionsReturnsActiveQuestions() { + // given + Club club = createClub(1); + ClubApplyQuestion q1 = createQuestion(club, 1, "지원 동기", true, 1, at(2026, 4, 1, 10, 0)); + ClubApplyQuestion q2 = createQuestion(club, 2, "관심 분야", false, 2, at(2026, 4, 1, 10, 0)); + + given(clubApplyQuestionRepository.findAllByClubIdOrderByDisplayOrderAsc(1)).willReturn(List.of(q1, q2)); + + // when + ClubApplyQuestionsResponse response = clubApplicationService.getApplyQuestions(1, 99); + + // then + assertThat(response.questions()).hasSize(2); + assertThat(response.questions().get(0).question()).isEqualTo("지원 동기"); + assertThat(response.questions().get(1).isRequired()).isFalse(); + } + + @Test + @DisplayName("getAppliedClubs는 사용자의 대기 중인 지원 목록을 반환한다") + void getAppliedClubsReturnsPendingApplications() { + // given + Club club = createClub(1); + User user = createUser(10, "2021136001", "지원자"); + ClubApply apply = ClubApply.of(club, user, null); + setId(apply, 100); + + given(clubApplyRepository.findAllPendingByUserIdWithClub(10)).willReturn(List.of(apply)); + + // when + ClubAppliedClubsResponse response = clubApplicationService.getAppliedClubs(10); + + // then + assertThat(response.appliedClubs()).hasSize(1); + } + + @Test + @DisplayName("getAppliedClubs는 대기 중인 지원이 없으면 빈 목록을 반환한다") + void getAppliedClubsReturnsEmptyWhenNoPendingApplications() { + // given + given(clubApplyRepository.findAllPendingByUserIdWithClub(10)).willReturn(List.of()); + + // when + ClubAppliedClubsResponse response = clubApplicationService.getAppliedClubs(10); + + // then + assertThat(response.appliedClubs()).isEmpty(); + } + + // ========== US-001: applyClub validation boundary cases ========== + + @Test + @DisplayName("applyClub은 필수 질문에 공백만 있는 답변을 거부한다") + void applyClubRejectsWhitespaceOnlyRequiredAnswer() { + // given + Club club = createClub(1); + User user = createUser(10, "2021136001", "지원자"); + ClubApplyQuestion requiredQuestion = createQuestion(club, 100, "지원 동기", true, 1, at(2026, 4, 1, 12, 0)); + ClubApplyRequest request = new ClubApplyRequest( + List.of(new ClubApplyRequest.InnerClubQuestionAnswer(100, " ")), + null + ); + + given(clubRepository.getById(1)).willReturn(club); + given(userRepository.getById(10)).willReturn(user); + given(clubMemberRepository.existsByClubIdAndUserId(1, 10)).willReturn(false); + given(clubApplyRepository.existsPendingByClubIdAndUserId(1, 10)).willReturn(false); + given(clubApplyQuestionRepository.findAllByClubIdOrderByDisplayOrderAsc(1)).willReturn( + List.of(requiredQuestion)); + + // when & then + assertErrorCode(() -> clubApplicationService.applyClub(1, 10, request), REQUIRED_CLUB_APPLY_ANSWER_MISSING); + verify(clubApplyRepository, never()).save(any()); + } + + @Test + @DisplayName("applyClub은 필수 질문에 null 답변을 거부한다") + void applyClubRejectsNullAnswerForRequiredQuestion() { + // given + Club club = createClub(1); + User user = createUser(10, "2021136001", "지원자"); + ClubApplyQuestion requiredQuestion = createQuestion(club, 100, "지원 동기", true, 1, at(2026, 4, 1, 12, 0)); + ClubApplyRequest request = new ClubApplyRequest( + List.of(new ClubApplyRequest.InnerClubQuestionAnswer(100, null)), + null + ); + + given(clubRepository.getById(1)).willReturn(club); + given(userRepository.getById(10)).willReturn(user); + given(clubMemberRepository.existsByClubIdAndUserId(1, 10)).willReturn(false); + given(clubApplyRepository.existsPendingByClubIdAndUserId(1, 10)).willReturn(false); + given(clubApplyQuestionRepository.findAllByClubIdOrderByDisplayOrderAsc(1)).willReturn( + List.of(requiredQuestion)); + + // when & then + assertErrorCode(() -> clubApplicationService.applyClub(1, 10, request), REQUIRED_CLUB_APPLY_ANSWER_MISSING); + verify(clubApplyRepository, never()).save(any()); + } + + @Test + @DisplayName("applyClub은 여러 운영진의 ID를 모두 제출 이벤트에 포함한다") + void applyClubPublishesEventWithMultipleManagers() { + // given + Club club = createClub(1); + User applicant = createUser(10, "2021136001", "지원자"); + User manager1 = createUser(20, "2021136002", "운영진1"); + User manager2 = createUser(21, "2021136003", "운영진2"); + ClubMember member1 = ClubMemberFixture.createManager(club, manager1); + ClubMember member2 = ClubMemberFixture.createManager(club, manager2); + ClubApply savedApply = ClubApply.of(club, applicant, null); + setId(savedApply, 300); + ClubApplyRequest request = new ClubApplyRequest(List.of(), null); + + given(clubRepository.getById(1)).willReturn(club); + given(userRepository.getById(10)).willReturn(applicant); + given(clubMemberRepository.existsByClubIdAndUserId(1, 10)).willReturn(false); + given(clubApplyRepository.existsPendingByClubIdAndUserId(1, 10)).willReturn(false); + given(clubApplyQuestionRepository.findAllByClubIdOrderByDisplayOrderAsc(1)).willReturn(List.of()); + given(clubApplyRepository.save(any(ClubApply.class))).willReturn(savedApply); + given(clubMemberRepository.findAllByClubIdAndPositionIn(1, + gg.agit.konect.domain.club.enums.ClubPosition.MANAGERS)) + .willReturn(List.of(member1, member2)); + + // when + clubApplicationService.applyClub(1, 10, request); + + // then + verify(applicationEventPublisher).publishEvent(ClubApplicationSubmittedEvent.of( + List.of(20, 21), + 300, + 1, + club.getName(), + applicant.getName() + )); + } + + // ========== US-002: Approve/reject logical state transitions ========== + + @Test + @DisplayName("approveClubApplication은 이미 승인된 지원서를 재승인하면 ALREADY_PROCESSED_CLUB_APPLY를 던진다") + void approveClubApplicationRejectsAlreadyApproved() { + // given + Club club = createClub(1); + User applicant = createUser(10, "2021136001", "지원자"); + ClubApply approvedApply = ClubApply.of(club, applicant, null); + approvedApply.approve(); + + given(clubRepository.getById(1)).willReturn(club); + given(clubApplyRepository.getByIdAndClubIdForUpdate(100, 1)).willReturn(approvedApply); + + // when & then + assertErrorCode(() -> clubApplicationService.approveClubApplication(1, 100, 99), ALREADY_PROCESSED_CLUB_APPLY); + verify(clubMemberRepository, never()).save(any()); + } + + @Test + @DisplayName("approveClubApplication은 이미 거절된 지원서를 승인하면 ALREADY_PROCESSED_CLUB_APPLY를 던진다") + void approveClubApplicationRejectsAlreadyRejected() { + // given + Club club = createClub(1); + User applicant = createUser(10, "2021136001", "지원자"); + ClubApply rejectedApply = ClubApply.of(club, applicant, null); + rejectedApply.reject(); + + given(clubRepository.getById(1)).willReturn(club); + given(clubApplyRepository.getByIdAndClubIdForUpdate(100, 1)).willReturn(rejectedApply); + + // when & then + assertErrorCode(() -> clubApplicationService.approveClubApplication(1, 100, 99), ALREADY_PROCESSED_CLUB_APPLY); + verify(clubMemberRepository, never()).save(any()); + } + + @Test + @DisplayName("rejectClubApplication은 이미 승인된 지원서를 거절하면 ALREADY_PROCESSED_CLUB_APPLY를 던진다") + void rejectClubApplicationRejectsAlreadyApproved() { + // given + Club club = createClub(1); + User applicant = createUser(10, "2021136001", "지원자"); + ClubApply approvedApply = ClubApply.of(club, applicant, null); + approvedApply.approve(); + + given(clubRepository.getById(1)).willReturn(club); + given(clubApplyRepository.getByIdAndClubIdForUpdate(100, 1)).willReturn(approvedApply); + + // when & then + assertErrorCode(() -> clubApplicationService.rejectClubApplication(1, 100, 99), ALREADY_PROCESSED_CLUB_APPLY); + assertThat(approvedApply.getStatus()).isEqualTo(ClubApplyStatus.APPROVED); + } + + @Test + @DisplayName("rejectClubApplication은 이미 거절된 지원서를 재거절하면 ALREADY_PROCESSED_CLUB_APPLY를 던진다") + void rejectClubApplicationRejectsAlreadyRejected() { + // given + Club club = createClub(1); + User applicant = createUser(10, "2021136001", "지원자"); + ClubApply rejectedApply = ClubApply.of(club, applicant, null); + rejectedApply.reject(); + + given(clubRepository.getById(1)).willReturn(club); + given(clubApplyRepository.getByIdAndClubIdForUpdate(100, 1)).willReturn(rejectedApply); + + // when & then + assertErrorCode(() -> clubApplicationService.rejectClubApplication(1, 100, 99), ALREADY_PROCESSED_CLUB_APPLY); + } + + // ========== US-003: replaceApplyQuestions edge cases ========== + + @Test + @DisplayName("replaceApplyQuestions는 isRequired가 null이면 기본값 true로 질문을 생성한다") + void replaceApplyQuestionsDefaultsIsRequiredToTrue() { + // given + Club club = createClub(1); + ClubApplyQuestion createdQuestion = createQuestion(club, 10, "새 질문", true, 1, at(2026, 4, 2, 10, 0)); + ClubApplyQuestionsReplaceRequest request = new ClubApplyQuestionsReplaceRequest(List.of( + new ClubApplyQuestionsReplaceRequest.ApplyQuestionRequest(null, "새 질문", null) + )); + + given(clubRepository.getById(1)).willReturn(club); + given(clubApplyQuestionRepository.findAllByClubIdOrderByDisplayOrderAsc(1)) + .willReturn(List.of()) + .willReturn(List.of(createdQuestion)); + + // when + clubApplicationService.replaceApplyQuestions(1, 99, request); + + // then + verify(clubApplyQuestionRepository).saveAll(argThat(list -> { + List questions = (List)list; + return questions.size() == 1 && questions.get(0).getIsRequired().equals(true); + })); + } + + @Test + @DisplayName("replaceApplyQuestions는 내용이 동일하면 displayOrder만 변경하고 soft delete하지 않는다") + void replaceApplyQuestionsOnlyChangesDisplayOrderWhenContentSame() { + // given + Club club = createClub(1); + ClubApplyQuestion question = createQuestion(club, 1, "지원 동기", true, 2, at(2026, 4, 1, 10, 0)); + // Same content, only repositioning to display order 1 + ClubApplyQuestionsReplaceRequest request = new ClubApplyQuestionsReplaceRequest(List.of( + new ClubApplyQuestionsReplaceRequest.ApplyQuestionRequest(1, "지원 동기", true) + )); + + given(clubRepository.getById(1)).willReturn(club); + given(clubApplyQuestionRepository.findAllByClubIdOrderByDisplayOrderAsc(1)) + .willReturn(List.of(question)) + .willReturn(List.of(question)); + + // when + clubApplicationService.replaceApplyQuestions(1, 99, request); + + // then + assertThat(question.getDisplayOrder()).isEqualTo(1); + assertThat(question.getDeletedAt()).isNull(); + verify(clubApplyQuestionRepository, never()).saveAll(any()); + } + + // ========== US-004: Question visibility boundary tests ========== + + @Test + @DisplayName("createdAt이 appliedAt과 정확히 같은 질문도 가입 시점에 보이는 것으로 처리된다") + void questionCreatedAtEqualToAppliedAtIsVisible() { + // given + Club club = createClub(1); + User user = createUser(10, "2021136001", "지원자"); + LocalDateTime appliedAt = at(2026, 4, 2, 10, 0); + ClubApply apply = createApprovedApply(club, user, 100, appliedAt); + Page page = new PageImpl<>(List.of(apply)); + ClubApplicationCondition condition = new ClubApplicationCondition(1, 10, null, null); + + ClubApplyQuestion question = createQuestion(club, 1, "질문", true, 1, appliedAt); + ClubApplyAnswer answer = ClubApplyAnswer.of(apply, question, "답변"); + + given(clubRepository.getById(1)).willReturn(club); + given(clubApplyQueryRepository.findApprovedMemberApplicationsByClubId(1, condition)).willReturn(page); + given(clubApplyAnswerRepository.findAllByApplyIdsWithQuestion(List.of(100))).willReturn(List.of(answer)); + given(clubApplyQuestionRepository.findAllCandidatesVisibleBetweenApplyTimes(1, appliedAt, appliedAt)) + .willReturn(List.of(question)); + + // when + ClubMemberApplicationAnswersResponse response = + clubApplicationService.getApprovedMemberApplicationAnswersList(1, 99, condition); + + // then + assertThat(response.applications()).hasSize(1); + assertThat(response.applications().get(0).answers()).hasSize(1); + assertThat(response.applications().get(0).answers().get(0).question()).isEqualTo("질문"); + } + + @Test + @DisplayName("deletedAt이 appliedAt과 정확히 같은 질문은 가입 시점에 보이지 않는다") + void questionDeletedAtEqualToAppliedAtIsNotVisible() { + // given + Club club = createClub(1); + User user = createUser(10, "2021136001", "지원자"); + LocalDateTime appliedAt = at(2026, 4, 2, 10, 0); + ClubApply apply = createApprovedApply(club, user, 100, appliedAt); + Page page = new PageImpl<>(List.of(apply)); + ClubApplicationCondition condition = new ClubApplicationCondition(1, 10, null, null); + + ClubApplyQuestion question = createQuestion(club, 1, "삭제된 질문", true, 1, at(2026, 4, 1, 9, 0)); + question.softDelete(appliedAt); + + given(clubRepository.getById(1)).willReturn(club); + given(clubApplyQueryRepository.findApprovedMemberApplicationsByClubId(1, condition)).willReturn(page); + given(clubApplyAnswerRepository.findAllByApplyIdsWithQuestion(List.of(100))).willReturn(List.of()); + given(clubApplyQuestionRepository.findAllCandidatesVisibleBetweenApplyTimes(1, appliedAt, appliedAt)) + .willReturn(List.of(question)); + + // when + ClubMemberApplicationAnswersResponse response = + clubApplicationService.getApprovedMemberApplicationAnswersList(1, 99, condition); + + // then - isAfter returns false for equal timestamps, so question is filtered out + assertThat(response.applications()).hasSize(1); + assertThat(response.applications().get(0).answers()).isEmpty(); + } + + @Test + @DisplayName("getApprovedMemberApplicationAnswersList는 지원서가 하나뿐이어도 정상 동작한다") + void getApprovedMemberApplicationAnswersListWithSingleApplication() { + // given + Club club = createClub(1); + User user = createUser(10, "2021136001", "지원자"); + LocalDateTime appliedAt = at(2026, 4, 2, 10, 0); + ClubApply apply = createApprovedApply(club, user, 100, appliedAt); + Page page = new PageImpl<>(List.of(apply)); + ClubApplicationCondition condition = new ClubApplicationCondition(1, 10, null, null); + + ClubApplyQuestion question = createQuestion(club, 1, "질문", true, 1, at(2026, 4, 1, 9, 0)); + ClubApplyAnswer answer = ClubApplyAnswer.of(apply, question, "답변"); + + given(clubRepository.getById(1)).willReturn(club); + given(clubApplyQueryRepository.findApprovedMemberApplicationsByClubId(1, condition)).willReturn(page); + given(clubApplyAnswerRepository.findAllByApplyIdsWithQuestion(List.of(100))).willReturn(List.of(answer)); + given(clubApplyQuestionRepository.findAllCandidatesVisibleBetweenApplyTimes(1, appliedAt, appliedAt)) + .willReturn(List.of(question)); + + // when + ClubMemberApplicationAnswersResponse response = + clubApplicationService.getApprovedMemberApplicationAnswersList(1, 99, condition); + + // then - minAppliedAt == maxAppliedAt + assertThat(response.applications()).hasSize(1); + assertThat(response.applications().get(0).applicationId()).isEqualTo(100); + } + + // ========== US-005: Untested service method coverage ========== + + @Test + @DisplayName("getApprovedMemberApplicationAnswers는 특정 회원의 최신 승인 지원서를 반환한다") + void getApprovedMemberApplicationAnswersReturnsLatestApproved() { + // given + Club club = createClub(1); + User targetUser = createUser(10, "2021136001", "회원"); + ClubMember targetMember = ClubMemberFixture.createMember(club, targetUser); + LocalDateTime appliedAt = at(2026, 4, 2, 10, 0); + ClubApply clubApply = createApprovedApply(club, targetUser, 100, appliedAt); + ClubApplyQuestion question = createQuestion(club, 200, "지원 동기", true, 1, at(2026, 4, 1, 9, 0)); + ClubApplyAnswer answer = ClubApplyAnswer.of(clubApply, question, "성장하고 싶습니다."); + + given(clubRepository.getById(1)).willReturn(club); + given(clubMemberRepository.getByClubIdAndUserId(1, 10)).willReturn(targetMember); + given(clubApplyRepository.getLatestApprovedByClubIdAndUserId(1, 10)).willReturn(clubApply); + given(clubApplyAnswerRepository.findAllByApplyIdWithQuestion(100)).willReturn(List.of(answer)); + given(clubApplyQuestionRepository.findAllVisibleAtApplyTime(1, appliedAt)).willReturn(List.of(question)); + + // when + ClubApplicationAnswersResponse response = + clubApplicationService.getApprovedMemberApplicationAnswers(1, 10, 99); + + // then + verify(clubPermissionValidator).validateManagerAccess(1, 99); + assertThat(response.applicationId()).isEqualTo(100); + assertThat(response.answers()).hasSize(1); + } + + @Test + @DisplayName("getClubApplications은 상시 모집 동아리의 전체 지원서를 조회한다") + void getClubApplicationsWithAlwaysRecruiting() { + // given + Club club = createClub(1); + ClubRecruitment recruitment = ClubRecruitment.of(null, null, true, "상시 모집", club); + ClubApplicationCondition condition = new ClubApplicationCondition(1, 10, null, null); + Page page = new PageImpl<>(List.of()); + + given(clubRepository.getById(1)).willReturn(club); + given(clubRecruitmentRepository.getByClubId(1)).willReturn(recruitment); + given(clubApplyQueryRepository.findAllByClubId(1, condition)).willReturn(page); + + // when + ClubApplicationsResponse response = clubApplicationService.getClubApplications(1, 99, condition); + + // then + verify(clubPermissionValidator).validateManagerAccess(1, 99); + verify(clubApplyQueryRepository).findAllByClubId(1, condition); + verify(clubApplyQueryRepository, never()).findAllByClubIdAndCreatedAtBetween(any(), any(), any(), any()); + assertThat(response.applications()).isEmpty(); + } + + @Test + @DisplayName("getClubApplications은 기간 모집 동아리의 기간 내 지원서만 조회한다") + void getClubApplicationsWithPeriodBasedRecruitment() { + // given + Club club = createClub(1); + LocalDateTime startAt = at(2026, 4, 1, 0, 0); + LocalDateTime endAt = at(2026, 4, 30, 23, 59); + ClubRecruitment recruitment = ClubRecruitment.of(startAt, endAt, false, "4월 모집", club); + ClubApplicationCondition condition = new ClubApplicationCondition(1, 10, null, null); + Page page = new PageImpl<>(List.of()); + + given(clubRepository.getById(1)).willReturn(club); + given(clubRecruitmentRepository.getByClubId(1)).willReturn(recruitment); + given(clubApplyQueryRepository.findAllByClubIdAndCreatedAtBetween(1, startAt, endAt, condition)).willReturn( + page); + + // when + ClubApplicationsResponse response = clubApplicationService.getClubApplications(1, 99, condition); + + // then + verify(clubApplyQueryRepository).findAllByClubIdAndCreatedAtBetween(1, startAt, endAt, condition); + verify(clubApplyQueryRepository, never()).findAllByClubId(any(), any()); + assertThat(response.applications()).isEmpty(); + } + + @Test + @DisplayName("getApprovedMemberApplications은 승인된 회원 지원서를 페이지네이션하여 반환한다") + void getApprovedMemberApplicationsReturnsPagedResults() { + // given + Club club = createClub(1); + User user = createUser(10, "2021136001", "회원"); + ClubApply apply = createApprovedApply(club, user, 100, at(2026, 4, 2, 10, 0)); + ClubApplicationCondition condition = new ClubApplicationCondition(1, 10, null, null); + Page page = new PageImpl<>(List.of(apply)); + + given(clubRepository.getById(1)).willReturn(club); + given(clubApplyQueryRepository.findApprovedMemberApplicationsByClubId(1, condition)).willReturn(page); + + // when + ClubApplicationsResponse response = + clubApplicationService.getApprovedMemberApplications(1, 99, condition); + + // then + verify(clubPermissionValidator).validateManagerAccess(1, 99); + assertThat(response.applications()).hasSize(1); + assertThat(response.totalCount()).isEqualTo(1); + } + + @Test + @DisplayName("getFeeInfo는 동아리 회비 정보를 반환한다") + void getFeeInfoReturnsClubFeeInfo() { + // given + Club club = createClubWithFeeInfo(1, "국민은행"); + Bank bank = org.mockito.Mockito.mock(Bank.class); + + given(clubRepository.getById(1)).willReturn(club); + given(bankRepository.getByName("국민은행")).willReturn(bank); + given(bank.getId()).willReturn(7); + + // when + ClubFeeInfoResponse response = clubApplicationService.getFeeInfo(1); + + // then + assertThat(response.bankId()).isEqualTo(7); + assertThat(response.bankName()).isEqualTo("국민은행"); + assertThat(response.accountNumber()).isEqualTo("123-456-7890"); + assertThat(response.accountHolder()).isEqualTo("BCSD"); + } + + @Test + @DisplayName("getFeeInfo는 회비 은행 정보가 없으면 bankRepository를 호출하지 않고 null을 반환한다") + void getFeeInfoReturnsNullBankWhenNoFeeInfo() { + // given + Club club = createClub(1); + + given(clubRepository.getById(1)).willReturn(club); + + // when + ClubFeeInfoResponse response = clubApplicationService.getFeeInfo(1); + + // then + assertThat(response.bankId()).isNull(); + assertThat(response.bankName()).isNull(); + verify(bankRepository, never()).getByName(any()); + } + + @Test + @DisplayName("replaceFeeInfo는 동아리 회비 정보를 업데이트하고 응답을 반환한다") + void replaceFeeInfoUpdatesClubFeeInfo() { + // given + Club club = createClub(1); + User managerUser = createUser(99, "2021136000", "운영진"); + Bank newBank = org.mockito.Mockito.mock(Bank.class); + ClubFeeInfoReplaceRequest request = new ClubFeeInfoReplaceRequest("5만원", 3, "987-654-3210", "BCSD"); + + given(userRepository.getById(99)).willReturn(managerUser); + given(clubRepository.getById(1)).willReturn(club); + given(bankRepository.getById(3)).willReturn(newBank); + given(newBank.getName()).willReturn("신한은행"); + + // when + ClubFeeInfoResponse response = clubApplicationService.replaceFeeInfo(1, 99, request); + + // then + verify(clubPermissionValidator).validateManagerAccess(1, 99); + assertThat(response.amount()).isEqualTo("5만원"); + assertThat(response.bankId()).isEqualTo(3); + assertThat(response.bankName()).isEqualTo("신한은행"); + assertThat(response.accountNumber()).isEqualTo("987-654-3210"); + assertThat(response.accountHolder()).isEqualTo("BCSD"); + } + + @Test + @DisplayName("replaceFeeInfo는 회비 정보가 일부만 입력되면 INVALID_REQUEST_BODY를 던진다") + void replaceFeeInfoRejectsPartialFeeInfo() { + // given + Club club = createClub(1); + User managerUser = createUser(99, "2021136000", "운영진"); + Bank bank = org.mockito.Mockito.mock(Bank.class); + ClubFeeInfoReplaceRequest request = new ClubFeeInfoReplaceRequest("5만원", 3, null, null); + + given(userRepository.getById(99)).willReturn(managerUser); + given(clubRepository.getById(1)).willReturn(club); + given(bankRepository.getById(3)).willReturn(bank); + given(bank.getName()).willReturn("신한은행"); + + // when & then + assertErrorCode(() -> clubApplicationService.replaceFeeInfo(1, 99, request), INVALID_REQUEST_BODY); + } + + private Club createClub(Integer clubId) { + return ClubFixture.createWithId(UniversityFixture.createWithId(1), clubId); + } + + private Club createFeeRequiredClub(Integer clubId) { + Club club = createClub(clubId); + club.updateSettings(null, null, true); + return club; + } + + private Club createClubWithFeeInfo(Integer clubId, String bankName) { + Club club = createFeeRequiredClub(clubId); + club.replaceFeeInfo("30000", bankName, "123-456-7890", "BCSD"); + return club; + } + + private User createUser(Integer id, String studentNumber, String name) { + return UserFixture.createUserWithId( + UniversityFixture.createWithId(1), + id, + name, + studentNumber, + gg.agit.konect.domain.user.enums.UserRole.USER + ); + } + + private ClubApplyQuestion createQuestion( + Club club, + Integer id, + String question, + boolean isRequired, + int displayOrder, + LocalDateTime createdAt + ) { + ClubApplyQuestion applyQuestion = ClubApplyQuestion.of(club, question, isRequired, displayOrder); + setId(applyQuestion, id); + setCreatedAt(applyQuestion, createdAt); + return applyQuestion; + } + + private ClubApply createPendingApply(Club club, User user, Integer id, LocalDateTime createdAt) { + ClubApply apply = ClubApply.of(club, user, null); + setId(apply, id); + setCreatedAt(apply, createdAt); + return apply; + } + + private ClubApply createApprovedApply(Club club, User user, Integer id, LocalDateTime createdAt) { + ClubApply apply = createPendingApply(club, user, id, createdAt); + apply.approve(); + return apply; + } + + private void setId(Object target, Integer id) { + ReflectionTestUtils.setField(target, "id", id); + } + + private void setCreatedAt(Object target, LocalDateTime createdAt) { + ReflectionTestUtils.setField(target, "createdAt", createdAt); + ReflectionTestUtils.setField(target, "updatedAt", createdAt); + } + + private LocalDateTime at(int year, int month, int day, int hour, int minute) { + return LocalDateTime.of(year, month, day, hour, minute); + } + + private void assertErrorCode(ThrowingCallable callable, ApiResponseCode errorCode) { + assertThatThrownBy(callable::call) + .isInstanceOf(CustomException.class) + .satisfies(exception -> assertThat(((CustomException)exception).getErrorCode()).isEqualTo(errorCode)); + } + + @FunctionalInterface + private interface ThrowingCallable { + void call(); + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/club/service/ClubMemberManagementServiceBatchTest.java b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubMemberManagementServiceBatchTest.java new file mode 100644 index 000000000..44d2666b2 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubMemberManagementServiceBatchTest.java @@ -0,0 +1,116 @@ +package gg.agit.konect.unit.domain.club.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.transaction.PlatformTransactionManager; + +import gg.agit.konect.domain.chat.service.ChatRoomMembershipService; +import gg.agit.konect.domain.club.dto.ClubPreMemberAddRequest; +import gg.agit.konect.domain.club.dto.ClubPreMemberBatchAddRequest; +import gg.agit.konect.domain.club.enums.ClubPosition; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.club.repository.ClubPreMemberRepository; +import gg.agit.konect.domain.club.repository.ClubRepository; +import gg.agit.konect.domain.club.service.ClubMemberManagementService; +import gg.agit.konect.domain.club.service.ClubPermissionValidator; +import gg.agit.konect.domain.university.enums.Campus; +import gg.agit.konect.domain.university.model.University; +import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; + +@ExtendWith(MockitoExtension.class) +class ClubMemberManagementServiceBatchTest { + + @InjectMocks + private ClubMemberManagementService clubMemberManagementService; + + @Mock + private ClubRepository clubRepository; + + @Mock + private ClubMemberRepository clubMemberRepository; + + @Mock + private ClubPreMemberRepository clubPreMemberRepository; + + @Mock + private ClubPermissionValidator clubPermissionValidator; + + @Mock + private UserRepository userRepository; + + @Mock + private ChatRoomMembershipService chatRoomMembershipService; + + @Mock + private PlatformTransactionManager transactionManager; + + private Club club; + private University university; + private Integer clubId = 1; + private Integer requesterId = 100; + + @BeforeEach + void setUp() { + university = University.builder() + .id(1) + .koreanName("Test University") + .campus(Campus.MAIN) + .build(); + + club = Club.builder() + .id(clubId) + .name("Test Club") + .university(university) + .build(); + } + + @Test + @DisplayName("배치 등록 요청 시 권한 검증이 먼저 수행된다") + void addPreMembersBatch_validatesPermissionFirst() { + // given + when(clubRepository.getById(clubId)).thenReturn(club); + doThrow(CustomException.of(ApiResponseCode.FORBIDDEN_CLUB_MANAGER_ACCESS)) + .when(clubPermissionValidator).validateManagerAccess(clubId, requesterId); + + ClubPreMemberAddRequest request = new ClubPreMemberAddRequest("2022000001", "학생1", ClubPosition.MEMBER); + ClubPreMemberBatchAddRequest batchRequest = new ClubPreMemberBatchAddRequest(List.of(request)); + + // when & then + CustomException exception = assertThrows(CustomException.class, () -> { + clubMemberManagementService.addPreMembersBatch(clubId, requesterId, batchRequest); + }); + assertThat(exception.getErrorCode()).isEqualTo(ApiResponseCode.FORBIDDEN_CLUB_MANAGER_ACCESS); + } + + @Test + @DisplayName("배치 등록 요청은 최소 1명 이상의 회원이 필요하다") + void addPreMembersBatch_requiresAtLeastOneMember() { + // given + ClubPreMemberBatchAddRequest batchRequest = new ClubPreMemberBatchAddRequest(List.of()); + + // when & then - Bean Validation은 컨트롤러 계층에서 처리되므로 서비스에서는 정상 동작 + // 실제로는 컨트롤러에서 @Valid로 인해 400 에러가 반환됨 + when(clubRepository.getById(clubId)).thenReturn(club); + + // 빈 리스트로 호출해도 서비스는 동작하되 결과는 빈 리스트 + var response = clubMemberManagementService.addPreMembersBatch(clubId, requesterId, batchRequest); + assertThat(response.totalCount()).isEqualTo(0); + assertThat(response.successCount()).isEqualTo(0); + assertThat(response.failedCount()).isEqualTo(0); + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/club/service/ClubMemberManagementServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubMemberManagementServiceTest.java new file mode 100644 index 000000000..b044909c1 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubMemberManagementServiceTest.java @@ -0,0 +1,686 @@ +package gg.agit.konect.unit.domain.club.service; + +import static gg.agit.konect.global.code.ApiResponseCode.AMBIGUOUS_USER_MATCH; +import static gg.agit.konect.global.code.ApiResponseCode.CANNOT_CHANGE_OWN_POSITION; +import static gg.agit.konect.global.code.ApiResponseCode.CANNOT_DELETE_CLUB_PRESIDENT; +import static gg.agit.konect.global.code.ApiResponseCode.CANNOT_MANAGE_HIGHER_POSITION; +import static gg.agit.konect.global.code.ApiResponseCode.CANNOT_REMOVE_NON_MEMBER; +import static gg.agit.konect.global.code.ApiResponseCode.CANNOT_REMOVE_SELF; +import static gg.agit.konect.global.code.ApiResponseCode.FORBIDDEN_MEMBER_POSITION_CHANGE; +import static gg.agit.konect.global.code.ApiResponseCode.ILLEGAL_ARGUMENT; +import static gg.agit.konect.global.code.ApiResponseCode.MANAGER_LIMIT_EXCEEDED; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.transaction.PlatformTransactionManager; + +import gg.agit.konect.domain.chat.service.ChatRoomMembershipService; +import gg.agit.konect.domain.club.dto.ClubPreMemberAddRequest; +import gg.agit.konect.domain.club.dto.ClubPreMemberAddResponse; +import gg.agit.konect.domain.club.dto.ClubPreMemberBatchAddRequest; +import gg.agit.konect.domain.club.dto.MemberPositionChangeRequest; +import gg.agit.konect.domain.club.enums.ClubPosition; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.model.ClubMember; +import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.club.repository.ClubPreMemberRepository; +import gg.agit.konect.domain.club.repository.ClubRepository; +import gg.agit.konect.domain.club.service.ClubMemberManagementService; +import gg.agit.konect.domain.club.service.ClubPermissionValidator; +import gg.agit.konect.domain.user.enums.UserRole; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.support.ServiceTestSupport; +import gg.agit.konect.support.fixture.ClubFixture; +import gg.agit.konect.support.fixture.ClubMemberFixture; +import gg.agit.konect.support.fixture.UniversityFixture; +import gg.agit.konect.support.fixture.UserFixture; + +class ClubMemberManagementServiceTest extends ServiceTestSupport { + + @Mock + private ClubRepository clubRepository; + + @Mock + private ClubMemberRepository clubMemberRepository; + + @Mock + private ClubPreMemberRepository clubPreMemberRepository; + + @Mock + private ClubPermissionValidator clubPermissionValidator; + + @Mock + private UserRepository userRepository; + + @Mock + private ChatRoomMembershipService chatRoomMembershipService; + + @Mock + private PlatformTransactionManager transactionManager; + + @InjectMocks + private ClubMemberManagementService clubMemberManagementService; + + @Test + @DisplayName("changeMemberPosition은 자기 자신의 직책 변경을 거부한다") + void changeMemberPositionRejectsSelfTarget() { + // given + Integer clubId = 1; + when(clubRepository.getById(clubId)).thenReturn(createClub()); + + // when & then + assertErrorCode( + () -> clubMemberManagementService.changeMemberPosition( + clubId, + 10, + 10, + new MemberPositionChangeRequest(ClubPosition.MANAGER) + ), + CANNOT_CHANGE_OWN_POSITION + ); + } + + @Test + @DisplayName("changeMemberPosition은 운영진 정원이 가득 차면 승격을 막는다") + void changeMemberPositionRejectsManagerPromotionWhenLimitExceeded() { + // given + Integer clubId = 1; + Integer requesterId = 100; + Integer targetUserId = 200; + Club club = createClub(); + User admin = UserFixture.createUserWithId(requesterId, "관리자", UserRole.ADMIN); + ClubMember targetMember = ClubMemberFixture.createMember(club, + UserFixture.createUserWithId(targetUserId, "대상", UserRole.USER)); + + when(clubRepository.getById(clubId)).thenReturn(club); + when(userRepository.getById(requesterId)).thenReturn(admin); + when(clubMemberRepository.getByClubIdAndUserId(clubId, targetUserId)).thenReturn(targetMember); + when(clubMemberRepository.countByClubIdAndPosition(clubId, ClubPosition.MANAGER)) + .thenReturn((long)ClubMemberManagementService.MAX_MANAGER_COUNT); + + // when & then + assertErrorCode( + () -> clubMemberManagementService.changeMemberPosition( + clubId, + targetUserId, + requesterId, + new MemberPositionChangeRequest(ClubPosition.MANAGER) + ), + MANAGER_LIMIT_EXCEEDED + ); + } + + @Test + @DisplayName("addPreMember는 동일 학번/이름으로 일치하는 유저가 여러 명이면 모호성 오류를 던진다") + void addPreMemberRejectsAmbiguousUserMatches() { + // given + Integer clubId = 1; + Integer requesterId = 10; + Club club = createClub(); + ClubPreMemberAddRequest request = new ClubPreMemberAddRequest("20240001", "홍길동", ClubPosition.MEMBER); + + when(clubRepository.getById(clubId)).thenReturn(club); + when( + userRepository.findAllByUniversityIdAndStudentNumber( + club.getUniversity().getId(), + request.studentNumber() + ) + ) + .thenReturn(List.of( + UserFixture.createUserWithId(1, request.name(), UserRole.USER), + UserFixture.createUserWithId(2, request.name(), UserRole.USER) + )); + + // when & then + assertErrorCode( + () -> clubMemberManagementService.addPreMember(clubId, requesterId, request), + AMBIGUOUS_USER_MATCH + ); + } + + @Test + @DisplayName("addPreMember는 정확히 일치하는 유저가 한 명이면 즉시 회원으로 추가한다") + void addPreMemberAddsDirectMemberWhenExactlyOneUserMatches() { + // given + Integer clubId = 1; + Integer requesterId = 10; + Club club = createClub(); + ClubPreMemberAddRequest request = new ClubPreMemberAddRequest("20240001", "홍길동", ClubPosition.MANAGER); + User matchedUser = UserFixture.createUserWithId(1, request.name(), UserRole.USER); + + when(clubRepository.getById(clubId)).thenReturn(club); + when( + userRepository.findAllByUniversityIdAndStudentNumber( + club.getUniversity().getId(), + request.studentNumber() + ) + ) + .thenReturn(List.of(matchedUser)); + when(clubMemberRepository.existsByClubIdAndUserId(clubId, matchedUser.getId())).thenReturn(false); + when(clubMemberRepository.save(org.mockito.ArgumentMatchers.any(ClubMember.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + ClubPreMemberAddResponse response = clubMemberManagementService.addPreMember(clubId, requesterId, request); + + // then + assertThat(response.name()).isEqualTo(request.name()); + assertThat(response.studentNumber()).isEqualTo(matchedUser.getStudentNumber()); + assertThat(response.clubPosition()).isEqualTo(ClubPosition.MANAGER); + verify(clubPreMemberRepository).deleteByClubIdAndStudentNumber(clubId, request.studentNumber()); + verify(chatRoomMembershipService).addClubMember(org.mockito.ArgumentMatchers.any(ClubMember.class)); + } + + @Test + @DisplayName("changeMemberPosition은 관리자가 아닌 사용자의 canManage 검증에 실패하면 예외를 던진다") + void changeMemberPositionValidatesRequesterCanManageTarget() { + // given + Integer clubId = 1; + Integer requesterId = 100; + Integer targetUserId = 200; + Club club = createClub(); + User requesterUser = UserFixture.createUserWithId(requesterId, "요청자", UserRole.USER); + ClubMember requesterMember = ClubMemberFixture.createMember(club, requesterUser); + User targetUser = UserFixture.createUserWithId(targetUserId, "대상", UserRole.USER); + ClubMember targetMember = ClubMemberFixture.createManager(club, targetUser); + + when(clubRepository.getById(clubId)).thenReturn(club); + when(userRepository.getById(requesterId)).thenReturn(requesterUser); + when(clubMemberRepository.getByClubIdAndUserId(clubId, requesterId)).thenReturn(requesterMember); + when(clubMemberRepository.getByClubIdAndUserId(clubId, targetUserId)).thenReturn(targetMember); + + // when & then + assertErrorCode( + () -> clubMemberManagementService.changeMemberPosition( + clubId, + targetUserId, + requesterId, + new MemberPositionChangeRequest(ClubPosition.MEMBER) + ), + CANNOT_MANAGE_HIGHER_POSITION + ); + } + + @Test + @DisplayName("changeMemberPosition은 관리자의 canManage(newPosition) 검증에 실패하면 예외를 던진다") + void changeMemberPositionValidatesRequesterCanManageNewPosition() { + // given + Integer clubId = 1; + Integer requesterId = 100; + Integer targetUserId = 200; + Club club = createClub(); + User requesterUser = UserFixture.createUserWithId(requesterId, "요청자", UserRole.USER); + ClubMember requesterMember = ClubMemberFixture.createVicePresident(club, requesterUser); // 부회장이 요청 + User targetUser = UserFixture.createUserWithId(targetUserId, "대상", UserRole.USER); + ClubMember targetMember = ClubMemberFixture.createMember(club, targetUser); // 일반 회원을 부회장으로 승격 시도 + + when(clubRepository.getById(clubId)).thenReturn(club); + when(userRepository.getById(requesterId)).thenReturn(requesterUser); + when(clubMemberRepository.getByClubIdAndUserId(clubId, requesterId)).thenReturn(requesterMember); + when(clubMemberRepository.getByClubIdAndUserId(clubId, targetUserId)).thenReturn(targetMember); + + // when & then + assertErrorCode( + () -> clubMemberManagementService.changeMemberPosition( + clubId, + targetUserId, + requesterId, + new MemberPositionChangeRequest(ClubPosition.PRESIDENT) // 회장으로 승격 시도 (부회장 권한 밖) + ), + FORBIDDEN_MEMBER_POSITION_CHANGE + ); + } + + @Test + @DisplayName("getPreMembers는 정상 동작한다") + void getPreMembersWorksNormally() { + // given + Integer clubId = 1; + Integer requesterId = 10; + Club club = createClub(); + gg.agit.konect.domain.club.model.ClubPreMember preMember1 = gg.agit.konect.domain.club.model.ClubPreMember.builder() + .id(1) + .club(club) + .studentNumber("20240001") + .name("홍길동") + .clubPosition(ClubPosition.MEMBER) + .build(); + gg.agit.konect.domain.club.model.ClubPreMember preMember2 = gg.agit.konect.domain.club.model.ClubPreMember.builder() + .id(2) + .club(club) + .studentNumber("20240002") + .name("김철수") + .clubPosition(ClubPosition.MANAGER) + .build(); + + when(clubRepository.getById(clubId)).thenReturn(club); + when(clubPreMemberRepository.findAllByClubId(clubId)).thenReturn(List.of(preMember1, preMember2)); + + // when + gg.agit.konect.domain.club.dto.ClubPreMembersResponse response = clubMemberManagementService.getPreMembers( + clubId, requesterId); + + // then + assertThat(response.preMembers()).hasSize(2); + } + + @Test + @DisplayName("removePreMember는 정상 동작한다") + void removePreMemberWorksNormally() { + // given + Integer clubId = 1; + Integer preMemberId = 5; + Integer requesterId = 10; + Club club = createClub(); + gg.agit.konect.domain.club.model.ClubPreMember preMember = gg.agit.konect.domain.club.model.ClubPreMember.builder() + .id(preMemberId) + .club(club) + .studentNumber("20240001") + .name("홍길동") + .clubPosition(ClubPosition.MEMBER) + .build(); + + when(clubRepository.getById(clubId)).thenReturn(club); + when(clubPreMemberRepository.getByIdAndClubId(preMemberId, clubId)).thenReturn(preMember); + + // when + clubMemberManagementService.removePreMember(clubId, preMemberId, requesterId); + + // then + verify(clubPreMemberRepository).delete(preMember); + } + + @Test + @DisplayName("transferPresident는 자기 자신에게 이전 시도하면 예외를 던진다") + void transferPresidentValidatesNotSelf() { + // given + Integer clubId = 1; + Integer currentPresidentId = 100; + Club club = createClub(); + User presidentUser = UserFixture.createUserWithId(currentPresidentId, "회장", UserRole.USER); + ClubMember president = ClubMemberFixture.createPresident(club, presidentUser); + gg.agit.konect.domain.club.dto.PresidentTransferRequest request = new gg.agit.konect.domain.club.dto.PresidentTransferRequest( + currentPresidentId); + + when(clubRepository.getById(clubId)).thenReturn(club); + when(clubMemberRepository.getByClubIdAndUserId(clubId, currentPresidentId)).thenReturn(president); + + // when & then + assertErrorCode( + () -> clubMemberManagementService.transferPresident(clubId, currentPresidentId, request), + ILLEGAL_ARGUMENT + ); + } + + @Test + @DisplayName("transferPresident는 정상 이전 동작을 수행한다") + void transferPresidentWorksNormally() { + // given + Integer clubId = 1; + Integer currentPresidentId = 100; + Integer newPresidentId = 200; + Club club = createClub(); + User currentPresidentUser = UserFixture.createUserWithId(currentPresidentId, "현회장", UserRole.USER); + User newPresidentUser = UserFixture.createUserWithId(newPresidentId, "신회장", UserRole.USER); + ClubMember currentPresident = ClubMemberFixture.createPresident(club, currentPresidentUser); + ClubMember newPresident = ClubMemberFixture.createMember(club, newPresidentUser); + gg.agit.konect.domain.club.dto.PresidentTransferRequest request = new gg.agit.konect.domain.club.dto.PresidentTransferRequest( + newPresidentId); + + when(clubRepository.getById(clubId)).thenReturn(club); + when(clubMemberRepository.getByClubIdAndUserId(clubId, currentPresidentId)).thenReturn(currentPresident); + when(clubMemberRepository.getByClubIdAndUserId(clubId, newPresidentId)).thenReturn(newPresident); + + // when + List result = clubMemberManagementService.transferPresident(clubId, currentPresidentId, request); + + // then + assertThat(result).hasSize(2); + assertThat(currentPresident.getClubPosition()).isEqualTo(ClubPosition.MEMBER); + assertThat(newPresident.getClubPosition()).isEqualTo(ClubPosition.PRESIDENT); + } + + @Test + @DisplayName("changeVicePresident는 기존 부회장이 없는 경우 null VP 요청을 처리한다") + void changeVicePresidentHandlesNullVpWhenNoExistingVp() { + // given + Integer clubId = 1; + Integer requesterId = 100; + Club club = createClub(); + User presidentUser = UserFixture.createUserWithId(requesterId, "회장", UserRole.USER); + ClubMemberFixture.createPresident(club, presidentUser); + gg.agit.konect.domain.club.dto.VicePresidentChangeRequest request = new gg.agit.konect.domain.club.dto.VicePresidentChangeRequest( + null); + + when(clubRepository.getById(clubId)).thenReturn(club); + when(clubMemberRepository.findAllByClubIdAndPosition(clubId, ClubPosition.VICE_PRESIDENT)).thenReturn( + List.of()); + + // when + List result = clubMemberManagementService.changeVicePresident(clubId, requesterId, request); + + // then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("changeVicePresident는 기존 부회장을 다른 사용자로 교체한다") + void changeVicePresidentReplacesExistingVp() { + // given + Integer clubId = 1; + Integer requesterId = 100; + Integer currentVpId = 200; + Integer newVpId = 300; + Club club = createClub(); + User presidentUser = UserFixture.createUserWithId(requesterId, "회장", UserRole.USER); + User currentVpUser = UserFixture.createUserWithId(currentVpId, "현부회장", UserRole.USER); + User newVpUser = UserFixture.createUserWithId(newVpId, "신부회장", UserRole.USER); + ClubMember president = ClubMemberFixture.createPresident(club, presidentUser); + ClubMember currentVp = ClubMemberFixture.createVicePresident(club, currentVpUser); + ClubMember newVp = ClubMemberFixture.createMember(club, newVpUser); + gg.agit.konect.domain.club.dto.VicePresidentChangeRequest request = new gg.agit.konect.domain.club.dto.VicePresidentChangeRequest( + newVpId); + + when(clubRepository.getById(clubId)).thenReturn(club); + when(clubMemberRepository.findAllByClubIdAndPosition(clubId, ClubPosition.VICE_PRESIDENT)).thenReturn( + List.of(currentVp)); + when(clubMemberRepository.getByClubIdAndUserId(clubId, newVpId)).thenReturn(newVp); + + // when + List result = clubMemberManagementService.changeVicePresident(clubId, requesterId, request); + + // then + assertThat(result).hasSize(2); + assertThat(currentVp.getClubPosition()).isEqualTo(ClubPosition.MEMBER); + assertThat(newVp.getClubPosition()).isEqualTo(ClubPosition.VICE_PRESIDENT); + } + + @Test + @DisplayName("removeMember는 회장 제거 시도를 거부한다") + void removeMemberRejectsPresidentRemoval() { + // given + Integer clubId = 1; + Integer requesterId = 100; + Integer targetUserId = 200; + Club club = createClub(); + User requesterUser = UserFixture.createUserWithId(requesterId, "회장", UserRole.USER); + User targetUser = UserFixture.createUserWithId(targetUserId, "대상", UserRole.USER); + ClubMember target = ClubMemberFixture.createPresident(club, targetUser); + + when(clubRepository.getById(clubId)).thenReturn(club); + when(userRepository.getById(requesterId)).thenReturn(requesterUser); + when(clubMemberRepository.getByClubIdAndUserId(clubId, targetUserId)).thenReturn(target); + + // when & then + assertErrorCode( + () -> clubMemberManagementService.removeMember(clubId, targetUserId, requesterId), + CANNOT_DELETE_CLUB_PRESIDENT + ); + } + + @Test + @DisplayName("removeMember는 비회원 제거 시도를 거부한다") + void removeMemberRejectsNonMemberRemoval() { + // given + Integer clubId = 1; + Integer requesterId = 100; + Integer targetUserId = 200; + Club club = createClub(); + User requesterUser = UserFixture.createUserWithId(requesterId, "회장", UserRole.USER); + User targetUser = UserFixture.createUserWithId(targetUserId, "대상", UserRole.USER); + ClubMember requester = ClubMemberFixture.createPresident(club, requesterUser); + ClubMember target = ClubMemberFixture.createVicePresident(club, targetUser); + + when(clubRepository.getById(clubId)).thenReturn(club); + when(userRepository.getById(requesterId)).thenReturn(requesterUser); + when(clubMemberRepository.getByClubIdAndUserId(clubId, requesterId)).thenReturn(requester); + when(clubMemberRepository.getByClubIdAndUserId(clubId, targetUserId)).thenReturn(target); + + // when & then + assertErrorCode( + () -> clubMemberManagementService.removeMember(clubId, targetUserId, requesterId), + CANNOT_REMOVE_NON_MEMBER + ); + } + + @Test + @DisplayName("removeMember는 정상 제거 동작을 수행한다") + void removeMemberWorksNormally() { + // given + Integer clubId = 1; + Integer requesterId = 100; + Integer targetUserId = 200; + Club club = createClub(); + User requesterUser = UserFixture.createUserWithId(requesterId, "회장", UserRole.USER); + User targetUser = UserFixture.createUserWithId(targetUserId, "대상", UserRole.USER); + ClubMember requester = ClubMemberFixture.createPresident(club, requesterUser); + ClubMember target = ClubMemberFixture.createMember(club, targetUser); + + when(clubRepository.getById(clubId)).thenReturn(club); + when(userRepository.getById(requesterId)).thenReturn(requesterUser); + when(clubMemberRepository.getByClubIdAndUserId(clubId, requesterId)).thenReturn(requester); + when(clubMemberRepository.getByClubIdAndUserId(clubId, targetUserId)).thenReturn(target); + + // when + clubMemberManagementService.removeMember(clubId, targetUserId, requesterId); + + // then + verify(clubMemberRepository).delete(target); + verify(chatRoomMembershipService).removeClubMember(clubId, targetUserId); + } + + @Test + @DisplayName("changeVicePresident는 같은 부회장 재지정 시 아무 변화 없음") + void changeVicePresidentHandlesSameVicePresident() { + // given + Integer clubId = 1; + Integer requesterId = 100; + Integer currentVpId = 200; + Club club = createClub(); + User presidentUser = UserFixture.createUserWithId(requesterId, "회장", UserRole.USER); + User currentVpUser = UserFixture.createUserWithId(currentVpId, "부회장", UserRole.USER); + ClubMemberFixture.createPresident(club, presidentUser); + ClubMember currentVp = ClubMemberFixture.createVicePresident(club, currentVpUser); + gg.agit.konect.domain.club.dto.VicePresidentChangeRequest request = new gg.agit.konect.domain.club.dto.VicePresidentChangeRequest( + currentVpId); + + when(clubRepository.getById(clubId)).thenReturn(club); + when(clubMemberRepository.findAllByClubIdAndPosition(clubId, ClubPosition.VICE_PRESIDENT)).thenReturn( + List.of(currentVp)); + when(clubMemberRepository.getByClubIdAndUserId(clubId, currentVpId)).thenReturn(currentVp); + + // when + List result = clubMemberManagementService.changeVicePresident(clubId, requesterId, request); + + // then + assertThat(result).hasSize(1); + assertThat(currentVp.getClubPosition()).isEqualTo(ClubPosition.VICE_PRESIDENT); + } + + @Test + @DisplayName("changeVicePresident는 기존 VP 강등 후 새 VP 지정") + void changeVicePresidentDemotesOldVpAndAppointsNewVp() { + // given + Integer clubId = 1; + Integer requesterId = 100; + Integer currentVpId = 200; + Integer newVpId = 300; + Club club = createClub(); + User presidentUser = UserFixture.createUserWithId(requesterId, "회장", UserRole.USER); + User currentVpUser = UserFixture.createUserWithId(currentVpId, "현부회장", UserRole.USER); + User newVpUser = UserFixture.createUserWithId(newVpId, "신부회장", UserRole.USER); + ClubMember president = ClubMemberFixture.createPresident(club, presidentUser); + ClubMember currentVp = ClubMemberFixture.createVicePresident(club, currentVpUser); + ClubMember newVp = ClubMemberFixture.createMember(club, newVpUser); + gg.agit.konect.domain.club.dto.VicePresidentChangeRequest request = new gg.agit.konect.domain.club.dto.VicePresidentChangeRequest( + newVpId); + + when(clubRepository.getById(clubId)).thenReturn(club); + when(clubMemberRepository.findAllByClubIdAndPosition(clubId, ClubPosition.VICE_PRESIDENT)).thenReturn( + List.of(currentVp)); + when(clubMemberRepository.getByClubIdAndUserId(clubId, newVpId)).thenReturn(newVp); + + // when + List result = clubMemberManagementService.changeVicePresident(clubId, requesterId, request); + + // then + assertThat(result).hasSize(2); + assertThat(currentVp.getClubPosition()).isEqualTo(ClubPosition.MEMBER); + assertThat(newVp.getClubPosition()).isEqualTo(ClubPosition.VICE_PRESIDENT); + } + + @Test + @DisplayName("transferPresident는 이전 후 기존 회장이 MEMBER로 변경됨") + void transferPresidentChangesOldPresidentToMember() { + // given + Integer clubId = 1; + Integer currentPresidentId = 100; + Integer newPresidentId = 200; + Club club = createClub(); + User currentPresidentUser = UserFixture.createUserWithId(currentPresidentId, "현회장", UserRole.USER); + User newPresidentUser = UserFixture.createUserWithId(newPresidentId, "신회장", UserRole.USER); + ClubMember currentPresident = ClubMemberFixture.createPresident(club, currentPresidentUser); + ClubMember newPresident = ClubMemberFixture.createManager(club, newPresidentUser); + gg.agit.konect.domain.club.dto.PresidentTransferRequest request = new gg.agit.konect.domain.club.dto.PresidentTransferRequest( + newPresidentId); + + when(clubRepository.getById(clubId)).thenReturn(club); + when(clubMemberRepository.getByClubIdAndUserId(clubId, currentPresidentId)).thenReturn(currentPresident); + when(clubMemberRepository.getByClubIdAndUserId(clubId, newPresidentId)).thenReturn(newPresident); + + // when + List result = clubMemberManagementService.transferPresident(clubId, currentPresidentId, request); + + // then + assertThat(result).hasSize(2); + assertThat(currentPresident.getClubPosition()).isEqualTo(ClubPosition.MEMBER); + assertThat(newPresident.getClubPosition()).isEqualTo(ClubPosition.PRESIDENT); + } + + @Test + @DisplayName("removeMember는 자기 자신 제거 시도를 거부한다") + void removeMemberRejectsSelfRemoval() { + // given + Integer clubId = 1; + Integer requesterId = 100; + Club club = createClub(); + + when(clubRepository.getById(clubId)).thenReturn(club); + + // when & then + assertErrorCode( + () -> clubMemberManagementService.removeMember(clubId, requesterId, requesterId), + CANNOT_REMOVE_SELF + ); + } + + @Test + @DisplayName("changeMemberPosition은 VP에서 VP로 변경 시도 시 validatePositionLimit 통과") + void changeMemberPositionAllowsVpToVpChange() { + // given + Integer clubId = 1; + Integer requesterId = 100; + Integer targetUserId = 200; + Club club = createClub(); + User admin = UserFixture.createUserWithId(requesterId, "관리자", UserRole.ADMIN); + ClubMember targetMember = ClubMemberFixture.createVicePresident(club, + UserFixture.createUserWithId(targetUserId, "대상", UserRole.USER)); + + when(clubRepository.getById(clubId)).thenReturn(club); + when(userRepository.getById(requesterId)).thenReturn(admin); + when(clubMemberRepository.getByClubIdAndUserId(clubId, targetUserId)).thenReturn(targetMember); + + // when + ClubMember result = clubMemberManagementService.changeMemberPosition( + clubId, + targetUserId, + requesterId, + new MemberPositionChangeRequest(ClubPosition.VICE_PRESIDENT) + ); + + // then + assertThat(result.getClubPosition()).isEqualTo(ClubPosition.VICE_PRESIDENT); + } + + @Test + @DisplayName("addPreMembersBatch는 기존 멤버가 포함된 경우 스킽한다") + void addPreMembersBatchSkipsExistingMembers() { + // given + Integer clubId = 1; + Integer requesterId = 10; + Club club = createClub(); + ClubPreMemberBatchAddRequest request = new ClubPreMemberBatchAddRequest(List.of( + new ClubPreMemberAddRequest("20240001", "홍길동", ClubPosition.MEMBER), + new ClubPreMemberAddRequest("20240002", "김철수", ClubPosition.MANAGER) + )); + User existingUser = UserFixture.createUserWithId(UniversityFixture.create(), 1, "홍길동", "20240001", + UserRole.USER); + + when(clubRepository.getById(clubId)).thenReturn(club); + when(userRepository.findAllByUniversityIdAndStudentNumber(club.getUniversity().getId(), "20240001")) + .thenReturn(List.of(existingUser)); + when(clubMemberRepository.existsByClubIdAndUserId(clubId, existingUser.getId())).thenReturn(true); + when(userRepository.findAllByUniversityIdAndStudentNumber(club.getUniversity().getId(), "20240002")) + .thenReturn(List.of()); + + // when + gg.agit.konect.domain.club.dto.ClubPreMemberBatchAddResponse response = + clubMemberManagementService.addPreMembersBatch(clubId, requesterId, request); + + // then + assertThat(response.results()).hasSize(2); + assertThat(response.results().get(0).success()).isFalse(); + assertThat(response.results().get(0).errorCode()).isEqualTo("ALREADY_CLUB_MEMBER"); + } + + @Test + @DisplayName("removeMember는 관리자가 관리자 제거 시도를 거부한다") + void removeMemberRejectsManagerRemovingManager() { + // given + Integer clubId = 1; + Integer requesterId = 100; + Integer targetUserId = 200; + Club club = createClub(); + User requesterUser = UserFixture.createUserWithId(requesterId, "요청자", UserRole.USER); + User targetUser = UserFixture.createUserWithId(targetUserId, "대상", UserRole.USER); + ClubMember requester = ClubMemberFixture.createManager(club, requesterUser); + ClubMember target = ClubMemberFixture.createManager(club, targetUser); + + when(clubRepository.getById(clubId)).thenReturn(club); + when(userRepository.getById(requesterId)).thenReturn(requesterUser); + when(clubMemberRepository.getByClubIdAndUserId(clubId, requesterId)).thenReturn(requester); + when(clubMemberRepository.getByClubIdAndUserId(clubId, targetUserId)).thenReturn(target); + + // when & then + assertErrorCode( + () -> clubMemberManagementService.removeMember(clubId, targetUserId, requesterId), + CANNOT_MANAGE_HIGHER_POSITION + ); + } + + private Club createClub() { + return ClubFixture.createWithId(UniversityFixture.createWithId(1), 1); + } + + private void assertErrorCode(ThrowingCallable callable, ApiResponseCode errorCode) { + assertThatThrownBy(callable::call) + .isInstanceOf(CustomException.class) + .satisfies(exception -> assertThat(((CustomException)exception).getErrorCode()).isEqualTo(errorCode)); + } + + @FunctionalInterface + private interface ThrowingCallable { + void call(); + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/club/service/ClubMemberSheetServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubMemberSheetServiceTest.java new file mode 100644 index 000000000..663e6eda9 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubMemberSheetServiceTest.java @@ -0,0 +1,215 @@ +package gg.agit.konect.unit.domain.club.service; + +import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_CLUB_SHEET_ID; +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.Mockito.verify; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import gg.agit.konect.domain.club.dto.ClubMemberSheetSyncResponse; +import gg.agit.konect.domain.club.dto.ClubSheetIdUpdateRequest; +import gg.agit.konect.domain.club.enums.ClubSheetSortKey; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.club.repository.ClubPreMemberRepository; +import gg.agit.konect.domain.club.repository.ClubRepository; +import gg.agit.konect.domain.club.service.ClubMemberSheetService; +import gg.agit.konect.domain.club.service.ClubPermissionValidator; +import gg.agit.konect.domain.club.service.SheetHeaderMapper; +import gg.agit.konect.domain.club.service.SheetSyncExecutor; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.support.ServiceTestSupport; +import gg.agit.konect.support.fixture.ClubFixture; +import gg.agit.konect.support.fixture.UniversityFixture; + +class ClubMemberSheetServiceTest extends ServiceTestSupport { + + @Mock + private ClubRepository clubRepository; + + @Mock + private ClubMemberRepository clubMemberRepository; + + @Mock + private ClubPreMemberRepository clubPreMemberRepository; + + @Mock + private ClubPermissionValidator clubPermissionValidator; + + @Mock + private SheetSyncExecutor sheetSyncExecutor; + + @Mock + private SheetHeaderMapper sheetHeaderMapper; + + @Mock + private ObjectMapper objectMapper; + + @InjectMocks + private ClubMemberSheetService clubMemberSheetService; + + @Test + @DisplayName("시트 동기화 수에 사전 회원도 포함한다") + void syncMembersToSheetIncludesPreMembersInCount() { + // given + Integer clubId = 1; + Integer requesterId = 2; + String spreadsheetId = "spreadsheet-id"; + Club club = ClubFixture.create(UniversityFixture.create()); + club.updateGoogleSheetId(spreadsheetId); + + given(clubRepository.getById(clubId)).willReturn(club); + given(clubMemberRepository.countByClubId(clubId)).willReturn(2L); + given(clubPreMemberRepository.countByClubId(clubId)).willReturn(3L); + + // when + ClubMemberSheetSyncResponse response = clubMemberSheetService.syncMembersToSheet( + clubId, + requesterId, + ClubSheetSortKey.POSITION, + true + ); + + // then + verify(clubPermissionValidator).validateManagerAccess(clubId, requesterId); + verify(sheetSyncExecutor).executeWithSort(clubId, ClubSheetSortKey.POSITION, true); + assertThat(response.syncedMemberCount()).isEqualTo(5); + assertThat(response.sheetUrl()) + .isEqualTo("https://docs.google.com/spreadsheets/d/" + spreadsheetId + "/edit"); + } + + @Test + @DisplayName("updateSheetId는 정상 동작한다") + void updateSheetIdWorksNormally() throws JsonProcessingException { + // given + Integer clubId = 1; + Integer requesterId = 2; + String spreadsheetUrl = "https://docs.google.com/spreadsheets/d/test-sheet-id/edit"; + Club club = ClubFixture.create(UniversityFixture.create()); + ClubSheetIdUpdateRequest request = new ClubSheetIdUpdateRequest(spreadsheetUrl); + gg.agit.konect.domain.club.model.SheetColumnMapping mapping = gg.agit.konect.domain.club.model.SheetColumnMapping.defaultMapping(); + SheetHeaderMapper.SheetAnalysisResult analysisResult = new SheetHeaderMapper.SheetAnalysisResult( + mapping, + null, + null + ); + + given(clubRepository.getById(clubId)).willReturn(club); + given(sheetHeaderMapper.analyzeAllSheets("test-sheet-id")).willReturn(analysisResult); + given(objectMapper.writeValueAsString(analysisResult.memberListMapping().toMap())).willReturn("{}"); + + // when + clubMemberSheetService.updateSheetId(clubId, requesterId, request); + + // then + verify(clubPermissionValidator).validateManagerAccess(clubId, requesterId); + verify(sheetHeaderMapper).analyzeAllSheets("test-sheet-id"); + assertThat(club.getGoogleSheetId()).isEqualTo("test-sheet-id"); + } + + @Test + @DisplayName("syncMembersToSheet는 sheetId가 null인 경우 NOT_FOUND_CLUB_SHEET_ID 예외를 던진다") + void syncMembersToSheetThrowsNotFoundClubSheetIdWhenSheetIdIsNull() { + // given + Integer clubId = 1; + Integer requesterId = 2; + Club club = ClubFixture.create(UniversityFixture.create()); + + given(clubRepository.getById(clubId)).willReturn(club); + + // when & then + assertThatThrownBy(() -> clubMemberSheetService.syncMembersToSheet( + clubId, + requesterId, + ClubSheetSortKey.POSITION, + true + )) + .isInstanceOf(CustomException.class) + .satisfies(exception -> assertThat(((CustomException)exception).getErrorCode()).isEqualTo( + NOT_FOUND_CLUB_SHEET_ID)); + } + + @Test + @DisplayName("syncMembersToSheet는 sheetId가 blank인 경우 NOT_FOUND_CLUB_SHEET_ID 예외를 던진다") + void syncMembersToSheetThrowsNotFoundClubSheetIdWhenSheetIdIsBlank() { + // given + Integer clubId = 1; + Integer requesterId = 2; + Club club = ClubFixture.create(UniversityFixture.create()); + club.updateGoogleSheetId(" "); + + given(clubRepository.getById(clubId)).willReturn(club); + + // when & then + assertThatThrownBy(() -> clubMemberSheetService.syncMembersToSheet( + clubId, + requesterId, + ClubSheetSortKey.POSITION, + true + )) + .isInstanceOf(CustomException.class) + .satisfies(exception -> assertThat(((CustomException)exception).getErrorCode()).isEqualTo( + NOT_FOUND_CLUB_SHEET_ID)); + } + + @Test + @DisplayName("syncMembersToSheet는 빈 동아리(멤버 0명)에 대해 정상 동작한다") + void syncMembersToSheetHandlesEmptyClub() { + // given + Integer clubId = 1; + Integer requesterId = 2; + String spreadsheetId = "spreadsheet-id"; + Club club = ClubFixture.create(UniversityFixture.create()); + club.updateGoogleSheetId(spreadsheetId); + + given(clubRepository.getById(clubId)).willReturn(club); + given(clubMemberRepository.countByClubId(clubId)).willReturn(0L); + given(clubPreMemberRepository.countByClubId(clubId)).willReturn(0L); + + // when + ClubMemberSheetSyncResponse response = clubMemberSheetService.syncMembersToSheet( + clubId, + requesterId, + ClubSheetSortKey.POSITION, + true + ); + + // then + verify(clubPermissionValidator).validateManagerAccess(clubId, requesterId); + verify(sheetSyncExecutor).executeWithSort(clubId, ClubSheetSortKey.POSITION, true); + assertThat(response.syncedMemberCount()).isEqualTo(0); + assertThat(response.sheetUrl()) + .isEqualTo("https://docs.google.com/spreadsheets/d/" + spreadsheetId + "/edit"); + } + + @Test + @DisplayName("updateSheetId는 null memberListMapping 분석 결과 시 NullPointerException이 발생한다") + void updateSheetIdThrowsNpeWhenMemberListMappingIsNull() throws JsonProcessingException { + // given + Integer clubId = 1; + Integer requesterId = 2; + String spreadsheetUrl = "https://docs.google.com/spreadsheets/d/test-sheet-id/edit"; + Club club = ClubFixture.create(UniversityFixture.create()); + ClubSheetIdUpdateRequest request = new ClubSheetIdUpdateRequest(spreadsheetUrl); + SheetHeaderMapper.SheetAnalysisResult analysisResult = new SheetHeaderMapper.SheetAnalysisResult( + null, + null, + null + ); + + given(clubRepository.getById(clubId)).willReturn(club); + given(sheetHeaderMapper.analyzeAllSheets("test-sheet-id")).willReturn(analysisResult); + + // when & then + assertThatThrownBy(() -> clubMemberSheetService.updateSheetId(clubId, requesterId, request)) + .isInstanceOf(NullPointerException.class); + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/club/service/ClubServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubServiceTest.java new file mode 100644 index 000000000..e0460c601 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubServiceTest.java @@ -0,0 +1,932 @@ +package gg.agit.konect.unit.domain.club.service; + +import static gg.agit.konect.global.code.ApiResponseCode.FORBIDDEN_CLUB_MEMBER_ACCESS; +import static gg.agit.konect.global.code.ApiResponseCode.FORBIDDEN_ROLE_ACCESS; +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.argThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import java.util.Collections; +import java.util.List; + +import org.assertj.core.api.ThrowableAssert.ThrowingCallable; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.util.ReflectionTestUtils; + +import gg.agit.konect.domain.chat.model.ChatRoom; +import gg.agit.konect.domain.chat.repository.ChatRoomRepository; +import gg.agit.konect.domain.chat.service.ChatRoomMembershipService; +import gg.agit.konect.domain.club.dto.ClubBasicInfoUpdateRequest; +import gg.agit.konect.domain.club.dto.ClubCondition; +import gg.agit.konect.domain.club.dto.ClubCreateRequest; +import gg.agit.konect.domain.club.dto.ClubDetailResponse; +import gg.agit.konect.domain.club.dto.ClubMemberCondition; +import gg.agit.konect.domain.club.dto.ClubMembersResponse; +import gg.agit.konect.domain.club.dto.ClubMembershipsResponse; +import gg.agit.konect.domain.club.dto.ClubUpdateRequest; +import gg.agit.konect.domain.club.dto.ClubsResponse; +import gg.agit.konect.domain.club.dto.MyManagedClubResponse; +import gg.agit.konect.domain.club.enums.ClubCategory; +import gg.agit.konect.domain.club.enums.ClubPosition; +import gg.agit.konect.domain.club.enums.RecruitmentStatus; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.model.ClubApplyQuestion; +import gg.agit.konect.domain.club.model.ClubMember; +import gg.agit.konect.domain.club.model.ClubSummaryInfo; +import gg.agit.konect.domain.club.repository.ClubApplyQuestionRepository; +import gg.agit.konect.domain.club.repository.ClubApplyRepository; +import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.club.repository.ClubQueryRepository; +import gg.agit.konect.domain.club.repository.ClubRepository; +import gg.agit.konect.domain.club.service.ClubPermissionValidator; +import gg.agit.konect.domain.club.service.ClubService; +import gg.agit.konect.domain.user.enums.UserRole; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.support.ServiceTestSupport; +import gg.agit.konect.support.fixture.ClubFixture; +import gg.agit.konect.support.fixture.ClubMemberFixture; +import gg.agit.konect.support.fixture.UniversityFixture; +import gg.agit.konect.support.fixture.UserFixture; + +class ClubServiceTest extends ServiceTestSupport { + + @Mock + private ClubQueryRepository clubQueryRepository; + + @Mock + private ClubRepository clubRepository; + + @Mock + private ClubMemberRepository clubMemberRepository; + + @Mock + private ClubApplyRepository clubApplyRepository; + + @Mock + private ClubApplyQuestionRepository clubApplyQuestionRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private ClubPermissionValidator clubPermissionValidator; + + @Mock + private ChatRoomRepository chatRoomRepository; + + @Mock + private ChatRoomMembershipService chatRoomMembershipService; + + @InjectMocks + private ClubService clubService; + + @Test + @DisplayName("createClub은 관리자가 동아리를 생성하면 기본 질문과 회장 멤버십까지 함께 만든다") + void createClubCreatesClubPresidentAndDefaultQuestions() { + // given + Integer adminUserId = 1; + Integer presidentUserId = 2; + User admin = UserFixture.createUserWithId(adminUserId, "관리자", UserRole.ADMIN); + User presidentUser = UserFixture.createUserWithId(presidentUserId, "회장", UserRole.USER); + ClubCreateRequest request = new ClubCreateRequest( + presidentUserId, + "KONECT", + "테스트 동아리", + "상세 소개", + "https://example.com/club.png", + "학생회관 101호", + ClubCategory.ACADEMIC + ); + Club savedClub = request.toEntity(presidentUser.getUniversity()); + ReflectionTestUtils.setField(savedClub, "id", 100); + ClubMember savedPresident = ClubMemberFixture.createPresident(savedClub, presidentUser); + + given(userRepository.getById(adminUserId)).willReturn(admin); + given(userRepository.getById(presidentUserId)).willReturn(presidentUser); + given(clubRepository.save(any(Club.class))).willReturn(savedClub); + given(clubMemberRepository.save(any(ClubMember.class))).willReturn(savedPresident); + given(clubRepository.getById(savedClub.getId())).willReturn(savedClub); + given(clubMemberRepository.findAllByClubId(savedClub.getId())).willReturn(List.of(savedPresident)); + given(clubApplyRepository.existsPendingByClubIdAndUserId(savedClub.getId(), adminUserId)).willReturn(false); + + // when + ClubDetailResponse response = clubService.createClub(adminUserId, request); + + // then + verify(chatRoomRepository).save(any(ChatRoom.class)); + verify(clubMemberRepository).save(argThat(clubMember -> + clubMember.getClub().equals(savedClub) + && clubMember.getUser().equals(presidentUser) + && clubMember.getClubPosition() == ClubPosition.PRESIDENT + )); + verify(chatRoomMembershipService).addClubMember(savedPresident); + + ArgumentCaptor> questionCaptor = ArgumentCaptor.forClass(List.class); + verify(clubApplyQuestionRepository).saveAll(questionCaptor.capture()); + assertThat(questionCaptor.getValue()) + .extracting(ClubApplyQuestion::getQuestion, ClubApplyQuestion::getIsRequired, + ClubApplyQuestion::getDisplayOrder) + .containsExactly( + org.assertj.core.groups.Tuple.tuple("본인의 전화번호를 입력해주세요.", true, 1), + org.assertj.core.groups.Tuple.tuple("지원 동기", false, 2) + ); + + assertThat(response.id()).isEqualTo(savedClub.getId()); + assertThat(response.presidentUserId()).isEqualTo(presidentUserId); + assertThat(response.isMember()).isFalse(); + assertThat(response.isApplied()).isFalse(); + assertThat(response.recruitment().status()).isEqualTo(RecruitmentStatus.CLOSED); + } + + @Test + @DisplayName("createClub은 관리자가 아니면 접근을 거부한다") + void createClubRejectsNonAdminRequester() { + // given + Integer userId = 1; + User user = UserFixture.createUserWithId(userId, "일반 사용자", UserRole.USER); + ClubCreateRequest request = new ClubCreateRequest( + 2, + "KONECT", + "테스트 동아리", + "상세 소개", + "https://example.com/club.png", + "학생회관 101호", + ClubCategory.ACADEMIC + ); + given(userRepository.getById(userId)).willReturn(user); + + // when & then + assertErrorCode(() -> clubService.createClub(userId, request), FORBIDDEN_ROLE_ACCESS); + verify(clubRepository, never()).save(any(Club.class)); + } + + @Test + @DisplayName("getClubs는 지원 중이지만 아직 회원이 아닌 동아리만 pending 으로 표시한다") + void getClubsReturnsOnlyPendingAppliedNonMemberClubs() { + // given + Integer userId = 10; + User user = UserFixture.createUserWithId(UniversityFixture.createWithId(1), userId, "사용자", "20240010", + UserRole.USER); + ClubCondition condition = new ClubCondition(1, 10, "", false); + ClubSummaryInfo appliedOnlyClub = new ClubSummaryInfo( + 101, + "대기 동아리", + "https://example.com/club-1.png", + "학술", + "설명", + RecruitmentStatus.ONGOING, + false, + null + ); + ClubSummaryInfo joinedClub = new ClubSummaryInfo( + 102, + "가입 동아리", + "https://example.com/club-2.png", + "학술", + "설명", + RecruitmentStatus.ONGOING, + false, + null + ); + Page page = new PageImpl<>( + List.of(appliedOnlyClub, joinedClub), + PageRequest.of(0, 10), + 2 + ); + + given(userRepository.getById(userId)).willReturn(user); + given(clubQueryRepository.findAllByFilter(PageRequest.of(0, 10), "", false, user.getUniversity().getId())) + .willReturn(page); + given(clubApplyRepository.findClubIdsByUserIdAndClubIdIn(userId, List.of(101, 102))) + .willReturn(List.of(101, 102)); + given(clubMemberRepository.findClubIdsByUserIdAndClubIdIn(userId, List.of(101, 102))) + .willReturn(List.of(102)); + + // when + ClubsResponse response = clubService.getClubs(condition, userId); + + // then + assertThat(response.currentCount()).isEqualTo(2); + assertThat(response.clubs()) + .extracting(ClubsResponse.InnerClubResponse::id, ClubsResponse.InnerClubResponse::isPendingApproval) + .containsExactly( + org.assertj.core.groups.Tuple.tuple(101, true), + org.assertj.core.groups.Tuple.tuple(102, false) + ); + } + + @Test + @DisplayName("getClubs는 조회 결과가 비어 있으면 pending 집합도 비운다") + void getClubsReturnsEmptyPendingWhenNoClubExists() { + // given + Integer userId = 10; + User user = UserFixture.createUserWithId(UniversityFixture.createWithId(1), userId, "사용자", "20240010", + UserRole.USER); + ClubCondition condition = new ClubCondition(1, 10, null, null); + Page emptyPage = Page.empty(PageRequest.of(0, 10)); + + given(userRepository.getById(userId)).willReturn(user); + given(clubQueryRepository.findAllByFilter(PageRequest.of(0, 10), "", false, user.getUniversity().getId())) + .willReturn(emptyPage); + + // when + ClubsResponse response = clubService.getClubs(condition, userId); + + // then + assertThat(response.clubs()).isEmpty(); + verify(clubApplyRepository, never()).findClubIdsByUserIdAndClubIdIn(any(), any()); + verify(clubMemberRepository, never()).findClubIdsByUserIdAndClubIdIn(any(), any()); + } + + @Test + @DisplayName("getClubDetail은 회원 여부와 pending 지원 여부를 함께 계산한다") + void getClubDetailCombinesMembershipAndPendingApplication() { + // given + Integer clubId = 1; + Integer userId = 20; + Club club = ClubFixture.createWithId(UniversityFixture.createWithId(1), clubId, "BCSD"); + User presidentUser = UserFixture.createUserWithId(1, "회장", UserRole.USER); + User memberUser = UserFixture.createUserWithId(2, "회원", UserRole.USER); + ClubMember president = ClubMemberFixture.createPresident(club, presidentUser); + ClubMember member = ClubMemberFixture.createMember(club, memberUser); + + given(clubRepository.getById(clubId)).willReturn(club); + given(clubMemberRepository.findAllByClubId(clubId)).willReturn(List.of(president, member)); + given(clubApplyRepository.existsPendingByClubIdAndUserId(clubId, userId)).willReturn(true); + + // when + ClubDetailResponse response = clubService.getClubDetail(clubId, userId); + + // then + assertThat(response.memberCount()).isEqualTo(2); + assertThat(response.presidentUserId()).isEqualTo(presidentUser.getId()); + assertThat(response.isMember()).isFalse(); + assertThat(response.isApplied()).isTrue(); + } + + @Test + @DisplayName("getManagedClubs는 관리자는 전체 동아리를, 일반 사용자는 manager 이상 소속만 반환한다") + void getManagedClubsReturnsAllForAdminAndManagerOnlyForUser() { + // given + Integer adminId = 1; + Integer managerId = 2; + User admin = UserFixture.createUserWithId(adminId, "관리자", UserRole.ADMIN); + User manager = UserFixture.createUserWithId(managerId, "운영진", UserRole.USER); + Club allClub = ClubFixture.createWithId(UniversityFixture.createWithId(1), 100, "전체 동아리"); + Club managedClub = ClubFixture.createWithId(UniversityFixture.createWithId(1), 200, "운영 동아리"); + ClubMember managerMember = ClubMemberFixture.createManager(managedClub, manager); + + given(userRepository.getById(adminId)).willReturn(admin); + given(userRepository.getById(managerId)).willReturn(manager); + given(clubRepository.findAll()).willReturn(List.of(allClub)); + given(clubMemberRepository.findAllByUserIdAndClubPositions(managerId, ClubPosition.MANAGERS)) + .willReturn(List.of(managerMember)); + + // when + ClubMembershipsResponse adminResponse = clubService.getManagedClubs(adminId); + ClubMembershipsResponse managerResponse = clubService.getManagedClubs(managerId); + + // then + assertThat(adminResponse.joinedClubs()) + .extracting(ClubMembershipsResponse.InnerJoinedClubResponse::id) + .containsExactly(allClub.getId()); + assertThat(managerResponse.joinedClubs()) + .extracting(ClubMembershipsResponse.InnerJoinedClubResponse::id, + ClubMembershipsResponse.InnerJoinedClubResponse::position) + .containsExactly( + org.assertj.core.groups.Tuple.tuple(managedClub.getId(), ClubPosition.MANAGER.getDescription()) + ); + } + + @Test + @DisplayName("getManagedClubDetail은 일반 사용자에게 manager 권한 검증 후 상세 정보를 반환한다") + void getManagedClubDetailValidatesManagerAccessForUser() { + // given + Integer clubId = 1; + Integer userId = 10; + Club club = ClubFixture.createWithId(UniversityFixture.createWithId(1), clubId, "BCSD"); + User user = UserFixture.createUserWithId(userId, "운영진", UserRole.USER); + ClubMember clubMember = ClubMemberFixture.createManager(club, user); + + given(clubRepository.getById(clubId)).willReturn(club); + given(userRepository.getById(userId)).willReturn(user); + given(clubMemberRepository.getByClubIdAndUserId(clubId, userId)).willReturn(clubMember); + + // when + MyManagedClubResponse response = clubService.getManagedClubDetail(clubId, userId); + + // then + verify(clubPermissionValidator).validateManagerAccess(clubId, user); + assertThat(response.clubId()).isEqualTo(clubId); + assertThat(response.name()).isEqualTo(user.getName()); + assertThat(response.position()).isEqualTo(ClubPosition.MANAGER.getDescription()); + } + + @Test + @DisplayName("getClubMembers는 admin과 manager에게는 학번을 그대로 보여준다") + void getClubMembersReturnsUnmaskedStudentNumbersForAdminAndManager() { + // given + Integer clubId = 1; + User admin = UserFixture.createUserWithId(1, "관리자", UserRole.ADMIN); + User managerUser = UserFixture.createUserWithId(2, "운영진", UserRole.USER); + User memberUser = UserFixture.createUserWithId(3, "회원", UserRole.USER); + Club club = ClubFixture.createWithId(UniversityFixture.createWithId(1), clubId, "BCSD"); + ClubMember managerMember = ClubMemberFixture.createManager(club, managerUser); + ClubMember member = ClubMemberFixture.createMember(club, memberUser); + ClubMemberCondition condition = new ClubMemberCondition(ClubPosition.MEMBER); + + given(userRepository.getById(admin.getId())).willReturn(admin); + given(userRepository.getById(managerUser.getId())).willReturn(managerUser); + given(clubMemberRepository.findByClubIdAndUserId(clubId, managerUser.getId())).willReturn( + java.util.Optional.of(managerMember)); + given(clubMemberRepository.findAllByClubIdAndPosition(clubId, ClubPosition.MEMBER)).willReturn(List.of(member)); + + // when + ClubMembersResponse adminResponse = clubService.getClubMembers(clubId, admin.getId(), condition); + ClubMembersResponse managerResponse = clubService.getClubMembers(clubId, managerUser.getId(), condition); + + // then + assertThat(adminResponse.clubMembers()) + .extracting(ClubMembersResponse.InnerClubMember::studentNumber) + .containsExactly(memberUser.getStudentNumber()); + assertThat(managerResponse.clubMembers()) + .extracting(ClubMembersResponse.InnerClubMember::studentNumber) + .containsExactly(memberUser.getStudentNumber()); + } + + @Test + @DisplayName("getClubMembers는 일반 회원에게는 마스킹된 학번을 반환하고 비회원은 거부한다") + void getClubMembersMasksStudentNumberForMemberAndRejectsNonMember() { + // given + Integer clubId = 1; + Integer memberUserId = 10; + Integer outsiderUserId = 20; + Club club = ClubFixture.createWithId(UniversityFixture.createWithId(1), clubId, "BCSD"); + User memberUser = UserFixture.createUserWithId(UniversityFixture.createWithId(1), memberUserId, "일반 회원", + "20241234", UserRole.USER); + User targetUser = UserFixture.createUserWithId(UniversityFixture.createWithId(1), 30, "조회 대상", "20249876", + UserRole.USER); + User outsider = UserFixture.createUserWithId(outsiderUserId, "외부인", UserRole.USER); + ClubMember requesterMember = ClubMemberFixture.createMember(club, memberUser); + ClubMember targetMember = ClubMemberFixture.createMember(club, targetUser); + + given(userRepository.getById(memberUserId)).willReturn(memberUser); + given(userRepository.getById(outsiderUserId)).willReturn(outsider); + given(clubMemberRepository.findByClubIdAndUserId(clubId, memberUserId)).willReturn( + java.util.Optional.of(requesterMember)); + given(clubMemberRepository.findByClubIdAndUserId(clubId, outsiderUserId)).willReturn( + java.util.Optional.empty()); + given(clubMemberRepository.findAllByClubId(clubId)).willReturn(List.of(targetMember)); + + // when + ClubMembersResponse response = clubService.getClubMembers(clubId, memberUserId, null); + + // then + assertThat(response.clubMembers()) + .extracting(ClubMembersResponse.InnerClubMember::studentNumber) + .containsExactly("*****876"); + + assertErrorCode( + () -> clubService.getClubMembers(clubId, outsiderUserId, null), + FORBIDDEN_CLUB_MEMBER_ACCESS + ); + } + + @Test + @DisplayName("getClubDetail은 회원이면 isMember=true이고 isApplied도 true이다") + void getClubDetailReturnsMemberAndAppliedWhenUserIsMember() { + // given + Integer clubId = 1; + Integer userId = 20; + Club club = ClubFixture.createWithId(UniversityFixture.createWithId(1), clubId, "BCSD"); + User presidentUser = UserFixture.createUserWithId(1, "회장", UserRole.USER); + User memberUser = UserFixture.createUserWithId(userId, "회원", UserRole.USER); + ClubMember president = ClubMemberFixture.createPresident(club, presidentUser); + ClubMember member = ClubMemberFixture.createMember(club, memberUser); + + given(clubRepository.getById(clubId)).willReturn(club); + given(clubMemberRepository.findAllByClubId(clubId)).willReturn(List.of(president, member)); + + // when + ClubDetailResponse response = clubService.getClubDetail(clubId, userId); + + // then + assertThat(response.isMember()).isTrue(); + assertThat(response.isApplied()).isTrue(); + verify(clubApplyRepository, never()).existsPendingByClubIdAndUserId(any(), any()); + } + + @Test + @DisplayName("getClubDetail은 회원도 아니고 지원하지도 않았으면 isMember=false, isApplied=false이다") + void getClubDetailReturnsFalseWhenUserIsNeitherMemberNorApplied() { + // given + Integer clubId = 1; + Integer userId = 99; + Club club = ClubFixture.createWithId(UniversityFixture.createWithId(1), clubId, "BCSD"); + User presidentUser = UserFixture.createUserWithId(1, "회장", UserRole.USER); + ClubMember president = ClubMemberFixture.createPresident(club, presidentUser); + + given(clubRepository.getById(clubId)).willReturn(club); + given(clubMemberRepository.findAllByClubId(clubId)).willReturn(List.of(president)); + given(clubApplyRepository.existsPendingByClubIdAndUserId(clubId, userId)).willReturn(false); + + // when + ClubDetailResponse response = clubService.getClubDetail(clubId, userId); + + // then + assertThat(response.isMember()).isFalse(); + assertThat(response.isApplied()).isFalse(); + } + + @Test + @DisplayName("getClubs는 지원한 동아리가 없으면 어떤 클럽도 pending으로 표시하지 않는다") + void getClubsReturnsNoPendingWhenUserHasNoApplications() { + // given + Integer userId = 10; + User user = UserFixture.createUserWithId(UniversityFixture.createWithId(1), userId, "사용자", "20240010", + UserRole.USER); + ClubCondition condition = new ClubCondition(1, 10, "", false); + ClubSummaryInfo club = new ClubSummaryInfo( + 101, "동아리", "https://example.com/club.png", "학술", "설명", + RecruitmentStatus.ONGOING, false, null + ); + Page page = new PageImpl<>(List.of(club), PageRequest.of(0, 10), 1); + + given(userRepository.getById(userId)).willReturn(user); + given(clubQueryRepository.findAllByFilter(PageRequest.of(0, 10), "", false, user.getUniversity().getId())) + .willReturn(page); + given(clubApplyRepository.findClubIdsByUserIdAndClubIdIn(userId, List.of(101))) + .willReturn(Collections.emptyList()); + + // when + ClubsResponse response = clubService.getClubs(condition, userId); + + // then + assertThat(response.clubs()) + .extracting(ClubsResponse.InnerClubResponse::isPendingApproval) + .containsExactly(false); + verify(clubMemberRepository, never()).findClubIdsByUserIdAndClubIdIn(any(), any()); + } + + @Test + @DisplayName("getClubMembers는 부회장에게도 마스킹 없이 학번을 반환한다") + void getClubMembersReturnsUnmaskedStudentNumbersForVicePresident() { + // given + Integer clubId = 1; + User vicePresidentUser = UserFixture.createUserWithId(2, "부회장", UserRole.USER); + User targetUser = UserFixture.createUserWithId(UniversityFixture.createWithId(1), 3, "회원", "20249876", + UserRole.USER); + Club club = ClubFixture.createWithId(UniversityFixture.createWithId(1), clubId, "BCSD"); + ClubMember vicePresident = ClubMemberFixture.createVicePresident(club, vicePresidentUser); + ClubMember targetMember = ClubMemberFixture.createMember(club, targetUser); + + given(userRepository.getById(vicePresidentUser.getId())).willReturn(vicePresidentUser); + given(clubMemberRepository.findByClubIdAndUserId(clubId, vicePresidentUser.getId())) + .willReturn(java.util.Optional.of(vicePresident)); + given(clubMemberRepository.findAllByClubId(clubId)).willReturn(List.of(targetMember)); + + // when + ClubMembersResponse response = clubService.getClubMembers(clubId, vicePresidentUser.getId(), null); + + // then + assertThat(response.clubMembers()) + .extracting(ClubMembersResponse.InnerClubMember::studentNumber) + .containsExactly(targetUser.getStudentNumber()); + } + + @Test + @DisplayName("getClubMembers는 일반 회원이 position 필터를 사용하면 마스킹된 결과를 반환한다") + void getClubMembersReturnsMaskedResultsForMemberWithPositionFilter() { + // given + Integer clubId = 1; + Integer memberUserId = 10; + Club club = ClubFixture.createWithId(UniversityFixture.createWithId(1), clubId, "BCSD"); + User memberUser = UserFixture.createUserWithId(UniversityFixture.createWithId(1), memberUserId, "일반 회원", + "20241234", UserRole.USER); + User targetUser = UserFixture.createUserWithId(UniversityFixture.createWithId(1), 30, "조회 대상", "20249876", + UserRole.USER); + ClubMember requesterMember = ClubMemberFixture.createMember(club, memberUser); + ClubMember targetMember = ClubMemberFixture.createMember(club, targetUser); + ClubMemberCondition condition = new ClubMemberCondition(ClubPosition.MEMBER); + + given(userRepository.getById(memberUserId)).willReturn(memberUser); + given(clubMemberRepository.findByClubIdAndUserId(clubId, memberUserId)) + .willReturn(java.util.Optional.of(requesterMember)); + given(clubMemberRepository.findAllByClubIdAndPosition(clubId, ClubPosition.MEMBER)) + .willReturn(List.of(targetMember)); + + // when + ClubMembersResponse response = clubService.getClubMembers(clubId, memberUserId, condition); + + // then + verify(clubMemberRepository).findAllByClubIdAndPosition(clubId, ClubPosition.MEMBER); + assertThat(response.clubMembers()) + .extracting(ClubMembersResponse.InnerClubMember::studentNumber) + .containsExactly("*****876"); + } + + @Test + @DisplayName("updateInfo는 매니저가 동아리 정보를 수정하면 club.updateInfo를 호출한다") + void updateInfoCallsClubUpdateInfoForManager() { + // given + Integer clubId = 1; + Integer userId = 10; + Club club = ClubFixture.createWithId(UniversityFixture.createWithId(1), clubId, "BCSD"); + User user = UserFixture.createUserWithId(userId, "운영진", UserRole.USER); + ClubUpdateRequest request = new ClubUpdateRequest("새 소개", "https://new.png", "신공 201호", "새 상세 소개"); + + given(userRepository.getById(userId)).willReturn(user); + given(clubRepository.getById(clubId)).willReturn(club); + + // when + clubService.updateInfo(clubId, userId, request); + + // then + verify(clubPermissionValidator).validateManagerAccess(clubId, userId); + assertThat(club.getDescription()).isEqualTo("새 소개"); + assertThat(club.getImageUrl()).isEqualTo("https://new.png"); + assertThat(club.getLocation()).isEqualTo("신공 201호"); + assertThat(club.getIntroduce()).isEqualTo("새 상세 소개"); + } + + @Test + @DisplayName("updateInfo는 매니저 권한이 없으면 예외를 던진다") + void updateInfoRejectsNonManagerAccess() { + // given + Integer clubId = 1; + Integer userId = 10; + User user = UserFixture.createUserWithId(userId, "일반 회원", UserRole.USER); + ClubUpdateRequest request = new ClubUpdateRequest("새 소개", "https://new.png", "신공 201호", "새 상세 소개"); + + given(userRepository.getById(userId)).willReturn(user); + given(clubRepository.getById(clubId)).willReturn( + ClubFixture.createWithId(UniversityFixture.createWithId(1), clubId, "BCSD")); + willThrow(CustomException.of(FORBIDDEN_ROLE_ACCESS)) + .given(clubPermissionValidator) + .validateManagerAccess(clubId, userId); + + // when & then + assertErrorCode(() -> clubService.updateInfo(clubId, userId, request), FORBIDDEN_ROLE_ACCESS); + } + + @Test + @DisplayName("updateBasicInfo는 매니저가 동아리 이름과 분과를 수정한다") + void updateBasicInfoUpdatesNameAndCategoryForManager() { + // given + Integer clubId = 1; + Integer userId = 10; + Club club = ClubFixture.createWithId(UniversityFixture.createWithId(1), clubId, "BCSD"); + User user = UserFixture.createUserWithId(userId, "매니저", UserRole.USER); + ClubBasicInfoUpdateRequest request = new ClubBasicInfoUpdateRequest("새 이름", ClubCategory.SPORTS); + + given(userRepository.getById(userId)).willReturn(user); + given(clubRepository.getById(clubId)).willReturn(club); + + // when + clubService.updateBasicInfo(clubId, userId, request); + + // then + verify(clubPermissionValidator).validateManagerAccess(clubId, user); + assertThat(club.getName()).isEqualTo("새 이름"); + assertThat(club.getClubCategory()).isEqualTo(ClubCategory.SPORTS); + } + + @Test + @DisplayName("updateBasicInfo는 매니저 권한이 없으면 예외를 던진다") + void updateBasicInfoRejectsNonManagerAccess() { + // given + Integer clubId = 1; + Integer userId = 10; + User user = UserFixture.createUserWithId(userId, "일반 회원", UserRole.USER); + ClubBasicInfoUpdateRequest request = new ClubBasicInfoUpdateRequest("새 이름", ClubCategory.SPORTS); + + given(userRepository.getById(userId)).willReturn(user); + given(clubRepository.getById(clubId)).willReturn( + ClubFixture.createWithId(UniversityFixture.createWithId(1), clubId, "BCSD")); + willThrow(CustomException.of(ApiResponseCode.FORBIDDEN_CLUB_MANAGER_ACCESS)) + .given(clubPermissionValidator) + .validateManagerAccess(clubId, user); + + // when & then + assertErrorCode( + () -> clubService.updateBasicInfo(clubId, userId, request), + ApiResponseCode.FORBIDDEN_CLUB_MANAGER_ACCESS + ); + } + + @Test + @DisplayName("getJoinedClubs는 사용자가 가입한 동아리 목록을 반환한다") + void getJoinedClubsReturnsUsersClubMemberships() { + // given + Integer userId = 10; + User user = UserFixture.createUserWithId(userId, "사용자", UserRole.USER); + Club club1 = ClubFixture.createWithId(UniversityFixture.createWithId(1), 100, "동아리1"); + Club club2 = ClubFixture.createWithId(UniversityFixture.createWithId(1), 200, "동아리2"); + ClubMember member1 = ClubMemberFixture.createMember(club1, user); + ClubMember member2 = ClubMemberFixture.createManager(club2, user); + + given(clubMemberRepository.findAllByUserId(userId)).willReturn(List.of(member1, member2)); + + // when + ClubMembershipsResponse response = clubService.getJoinedClubs(userId); + + // then + assertThat(response.joinedClubs()).hasSize(2); + assertThat(response.joinedClubs()) + .extracting(ClubMembershipsResponse.InnerJoinedClubResponse::id) + .containsExactly(100, 200); + } + + @Test + @DisplayName("getJoinedClubs는 가입한 동아리가 없으면 빈 목록을 반환한다") + void getJoinedClubsReturnsEmptyListWhenNoClubsJoined() { + // given + Integer userId = 10; + given(clubMemberRepository.findAllByUserId(userId)).willReturn(Collections.emptyList()); + + // when + ClubMembershipsResponse response = clubService.getJoinedClubs(userId); + + // then + assertThat(response.joinedClubs()).isEmpty(); + } + + @Test + @DisplayName("getManagedClubDetail은 관리자에게 forAdmin 응답을 반환한다") + void getManagedClubDetailReturnsForAdminResponseForAdmin() { + // given + Integer clubId = 1; + Integer adminId = 10; + Club club = ClubFixture.createWithId(UniversityFixture.createWithId(1), clubId, "BCSD"); + User admin = UserFixture.createUserWithId(adminId, "관리자", UserRole.ADMIN); + + given(clubRepository.getById(clubId)).willReturn(club); + given(userRepository.getById(adminId)).willReturn(admin); + + // when + MyManagedClubResponse response = clubService.getManagedClubDetail(clubId, adminId); + + // then + verify(clubPermissionValidator, never()).validateManagerAccess(any(Integer.class), any(Integer.class)); + assertThat(response.clubId()).isEqualTo(clubId); + assertThat(response.position()).isEqualTo(ClubPosition.PRESIDENT.getDescription()); + assertThat(response.name()).isEqualTo(admin.getName()); + } + + @Test + @DisplayName("getManagedClubs는 매니저 권한이 없는 일반 회원에게 빈 목록을 반환한다") + void getManagedClubsReturnsEmptyForRegularMemberWithNoManagerPositions() { + // given + Integer userId = 10; + User user = UserFixture.createUserWithId(userId, "일반 회원", UserRole.USER); + + given(userRepository.getById(userId)).willReturn(user); + given(clubMemberRepository.findAllByUserIdAndClubPositions(userId, ClubPosition.MANAGERS)) + .willReturn(Collections.emptyList()); + + // when + ClubMembershipsResponse response = clubService.getManagedClubs(userId); + + // then + assertThat(response.joinedClubs()).isEmpty(); + } + + @Test + @DisplayName("getClubDetail은 회장이 존재하지 않으면 NOT_FOUND_CLUB_PRESIDENT 예외를 던진다") + void getClubDetailThrowsWhenNoPresidentExists() { + // given + Integer clubId = 1; + Integer userId = 99; + Club club = ClubFixture.createWithId(UniversityFixture.createWithId(1), clubId, "BCSD"); + User memberUser = UserFixture.createUserWithId(10, "일반 회원", UserRole.USER); + ClubMember memberOnly = ClubMemberFixture.createMember(club, memberUser); + + given(clubRepository.getById(clubId)).willReturn(club); + given(clubMemberRepository.findAllByClubId(clubId)).willReturn(List.of(memberOnly)); + + // when & then + assertErrorCode( + () -> clubService.getClubDetail(clubId, userId), + ApiResponseCode.NOT_FOUND_CLUB_PRESIDENT + ); + } + + @Test + @DisplayName("getClubMembers는 condition이 있지만 position이 null이면 전체 회원을 조회한다") + void getClubMembersFetchesAllMembersWhenConditionHasNullPosition() { + // given + Integer clubId = 1; + Integer userId = 10; + Club club = ClubFixture.createWithId(UniversityFixture.createWithId(1), clubId, "BCSD"); + User user = UserFixture.createUserWithId(userId, "운영진", UserRole.USER); + User targetUser = UserFixture.createUserWithId(UniversityFixture.createWithId(1), 20, "회원", "20249876", + UserRole.USER); + ClubMember requester = ClubMemberFixture.createManager(club, user); + ClubMember targetMember = ClubMemberFixture.createMember(club, targetUser); + ClubMemberCondition condition = new ClubMemberCondition(null); + + given(userRepository.getById(userId)).willReturn(user); + given(clubMemberRepository.findByClubIdAndUserId(clubId, userId)).willReturn(java.util.Optional.of(requester)); + given(clubMemberRepository.findAllByClubId(clubId)).willReturn(List.of(requester, targetMember)); + + // when + ClubMembersResponse response = clubService.getClubMembers(clubId, userId, condition); + + // then + verify(clubMemberRepository).findAllByClubId(clubId); + verify(clubMemberRepository, never()).findAllByClubIdAndPosition(any(), any()); + assertThat(response.clubMembers()).hasSize(2); + } + + @Test + @DisplayName("getClubMembers는 회원이 없으면 빈 목록을 반환한다") + void getClubMembersReturnsEmptyListWhenNoMembers() { + // given + Integer clubId = 1; + Integer userId = 10; + User admin = UserFixture.createUserWithId(userId, "관리자", UserRole.ADMIN); + + given(userRepository.getById(userId)).willReturn(admin); + given(clubMemberRepository.findAllByClubId(clubId)).willReturn(Collections.emptyList()); + + // when + ClubMembersResponse response = clubService.getClubMembers(clubId, userId, null); + + // then + assertThat(response.clubMembers()).isEmpty(); + } + + @Test + @DisplayName("getManagedClubDetail은 매니저가 아닌 일반 사용자의 접근을 거부한다") + void getManagedClubDetailRejectsNonManagerNonAdminUser() { + // given + Integer clubId = 1; + Integer userId = 10; + User user = UserFixture.createUserWithId(userId, "일반 사용자", UserRole.USER); + Club club = ClubFixture.createWithId(UniversityFixture.createWithId(1), clubId, "BCSD"); + + given(clubRepository.getById(clubId)).willReturn(club); + given(userRepository.getById(userId)).willReturn(user); + willThrow(CustomException.of(FORBIDDEN_ROLE_ACCESS)) + .given(clubPermissionValidator) + .validateManagerAccess(clubId, user); + + // when & then + assertErrorCode(() -> clubService.getManagedClubDetail(clubId, userId), FORBIDDEN_ROLE_ACCESS); + verify(clubMemberRepository, never()).getByClubIdAndUserId(any(), any()); + } + + @Test + @DisplayName("getClubs는 id가 null인 ClubSummaryInfo를 pending 계산에서 제외한다") + void getClubsSkipsNullIdClubSummariesInPendingCalculation() { + // given + Integer userId = 10; + User user = UserFixture.createUserWithId(UniversityFixture.createWithId(1), userId, "사용자", "20240010", + UserRole.USER); + ClubCondition condition = new ClubCondition(1, 10, "", false); + ClubSummaryInfo nullIdClub = new ClubSummaryInfo( + null, "null id 동아리", "https://example.com/null.png", "학술", "설명", + RecruitmentStatus.ONGOING, false, null + ); + ClubSummaryInfo validClub = new ClubSummaryInfo( + 200, "정상 동아리", "https://example.com/valid.png", "학술", "설명", + RecruitmentStatus.ONGOING, false, null + ); + Page page = new PageImpl<>(List.of(nullIdClub, validClub), PageRequest.of(0, 10), 2); + + given(userRepository.getById(userId)).willReturn(user); + given(clubQueryRepository.findAllByFilter(PageRequest.of(0, 10), "", false, user.getUniversity().getId())) + .willReturn(page); + // null id는 필터링되므로 clubIds에는 200만 남는다 + given(clubApplyRepository.findClubIdsByUserIdAndClubIdIn(userId, List.of(200))) + .willReturn(List.of(200)); + given(clubMemberRepository.findClubIdsByUserIdAndClubIdIn(userId, List.of(200))) + .willReturn(Collections.emptyList()); + + // when + ClubsResponse response = clubService.getClubs(condition, userId); + + // then + // null id 클럽은 pending 계산에서 제외되었으므로 repository에는 [200]만 전달됨 + verify(clubApplyRepository).findClubIdsByUserIdAndClubIdIn(userId, List.of(200)); + assertThat(response.clubs()) + .extracting(ClubsResponse.InnerClubResponse::id, ClubsResponse.InnerClubResponse::isPendingApproval) + .containsExactly( + org.assertj.core.groups.Tuple.tuple(null, false), + org.assertj.core.groups.Tuple.tuple(200, true) + ); + } + + @Test + @DisplayName("getClubMembers는 학번이 3자 이하이면 마스킹 없이 그대로 반환한다") + void getClubMembersDoesNotMaskShortStudentNumber() { + // given + Integer clubId = 1; + Integer memberUserId = 10; + Club club = ClubFixture.createWithId(UniversityFixture.createWithId(1), clubId, "BCSD"); + User memberUser = UserFixture.createUserWithId(UniversityFixture.createWithId(1), memberUserId, "회원", + "20241234", UserRole.USER); + User shortIdUser = UserFixture.createUserWithId(UniversityFixture.createWithId(1), 30, "짧은학번", "123", + UserRole.USER); + ClubMember requester = ClubMemberFixture.createMember(club, memberUser); + ClubMember shortIdMember = ClubMemberFixture.createMember(club, shortIdUser); + + given(userRepository.getById(memberUserId)).willReturn(memberUser); + given(clubMemberRepository.findByClubIdAndUserId(clubId, memberUserId)) + .willReturn(java.util.Optional.of(requester)); + given(clubMemberRepository.findAllByClubId(clubId)).willReturn(List.of(requester, shortIdMember)); + + // when + ClubMembersResponse response = clubService.getClubMembers(clubId, memberUserId, null); + + // then + assertThat(response.clubMembers()) + .extracting(ClubMembersResponse.InnerClubMember::studentNumber) + .containsExactly("*****234", "123"); + } + + @Test + @DisplayName("getClubMembers는 학번이 null이면 null을 그대로 반환한다") + void getClubMembersReturnsNullWhenStudentNumberIsNull() { + // given + Integer clubId = 1; + Integer memberUserId = 10; + Club club = ClubFixture.createWithId(UniversityFixture.createWithId(1), clubId, "BCSD"); + User memberUser = UserFixture.createUserWithId(UniversityFixture.createWithId(1), memberUserId, "회원", + "20241234", UserRole.USER); + User nullStudentNumberUser = UserFixture.createUserWithId(UniversityFixture.createWithId(1), 30, "학번없음", null, + UserRole.USER); + ClubMember requester = ClubMemberFixture.createMember(club, memberUser); + ClubMember nullStudentNumberMember = ClubMemberFixture.createMember(club, nullStudentNumberUser); + + given(userRepository.getById(memberUserId)).willReturn(memberUser); + given(clubMemberRepository.findByClubIdAndUserId(clubId, memberUserId)) + .willReturn(java.util.Optional.of(requester)); + given(clubMemberRepository.findAllByClubId(clubId)).willReturn(List.of(requester, nullStudentNumberMember)); + + // when + ClubMembersResponse response = clubService.getClubMembers(clubId, memberUserId, null); + + // then + assertThat(response.clubMembers()) + .extracting(ClubMembersResponse.InnerClubMember::studentNumber) + .containsExactly("*****234", null); + } + + @Test + @DisplayName("getClubs는 모든 지원 동아리가 가입 상태이면 pending 집합이 비어있다") + void getClubsReturnsEmptyPendingWhenAllAppliedClubsAreAlsoJoined() { + // given + Integer userId = 10; + User user = UserFixture.createUserWithId(UniversityFixture.createWithId(1), userId, "사용자", "20240010", + UserRole.USER); + ClubCondition condition = new ClubCondition(1, 10, "", false); + ClubSummaryInfo club1 = new ClubSummaryInfo( + 101, "동아리1", "https://example.com/1.png", "학술", "설명", + RecruitmentStatus.ONGOING, false, null + ); + ClubSummaryInfo club2 = new ClubSummaryInfo( + 102, "동아리2", "https://example.com/2.png", "학술", "설명", + RecruitmentStatus.ONGOING, false, null + ); + Page page = new PageImpl<>(List.of(club1, club2), PageRequest.of(0, 10), 2); + + given(userRepository.getById(userId)).willReturn(user); + given(clubQueryRepository.findAllByFilter(PageRequest.of(0, 10), "", false, user.getUniversity().getId())) + .willReturn(page); + // 두 클럽 모두에 지원했고, 두 클럽 모두 가입 상태 + given(clubApplyRepository.findClubIdsByUserIdAndClubIdIn(userId, List.of(101, 102))) + .willReturn(List.of(101, 102)); + given(clubMemberRepository.findClubIdsByUserIdAndClubIdIn(userId, List.of(101, 102))) + .willReturn(List.of(101, 102)); + + // when + ClubsResponse response = clubService.getClubs(condition, userId); + + // then + assertThat(response.clubs()) + .extracting(ClubsResponse.InnerClubResponse::isPendingApproval) + .containsExactly(false, false); + } + + private void assertErrorCode(ThrowingCallable callable, ApiResponseCode errorCode) { + assertThatThrownBy(callable) + .isInstanceOf(CustomException.class) + .satisfies(exception -> assertThat(((CustomException)exception).getErrorCode()).isEqualTo(errorCode)); + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/club/service/GoogleSheetPermissionServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/club/service/GoogleSheetPermissionServiceTest.java new file mode 100644 index 000000000..9db7946f8 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/club/service/GoogleSheetPermissionServiceTest.java @@ -0,0 +1,488 @@ +package gg.agit.konect.unit.domain.club.service; + +import static gg.agit.konect.domain.club.service.GoogleApiTestUtils.googleException; +import static gg.agit.konect.domain.club.service.GoogleApiTestUtils.httpResponseException; +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.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +import com.google.api.services.drive.Drive; +import com.google.api.services.drive.model.File; +import com.google.api.services.drive.model.Permission; +import com.google.api.services.drive.model.PermissionList; +import com.google.auth.oauth2.ServiceAccountCredentials; + +import gg.agit.konect.domain.user.enums.Provider; +import gg.agit.konect.domain.user.model.UserOAuthAccount; +import gg.agit.konect.domain.user.repository.UserOAuthAccountRepository; +import gg.agit.konect.domain.club.service.GoogleSheetPermissionService; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.infrastructure.googlesheets.GoogleSheetsConfig; +import gg.agit.konect.support.ServiceTestSupport; + +class GoogleSheetPermissionServiceTest extends ServiceTestSupport { + + private static final Integer REQUESTER_ID = 1; + private static final String FILE_ID = "spreadsheet-id"; + private static final String REFRESH_TOKEN = "refresh-token"; + private static final String SERVICE_ACCOUNT_EMAIL = "service-account@konect.iam.gserviceaccount.com"; + + @Mock + private ServiceAccountCredentials serviceAccountCredentials; + + @Mock + private GoogleSheetsConfig googleSheetsConfig; + + @Mock + private UserOAuthAccountRepository userOAuthAccountRepository; + + @Mock + private UserOAuthAccount userOAuthAccount; + + @Mock + private Drive userDriveService; + + @Mock + private Drive googleDriveService; + + @Mock + private Drive.Permissions permissions; + + @Mock + private Drive.Permissions.List listRequest; + + @Mock + private Drive.Permissions.List nextPageListRequest; + + @Mock + private Drive.Permissions.Create createRequest; + + @Mock + private Drive.Permissions.Update updateRequest; + + @Mock + private Drive.Files files; + + @Mock + private Drive.Files.Get getFileRequest; + + @Mock + private Drive.Files serviceAccountFiles; + + @Mock + private Drive.Files.Get serviceAccountGetFileRequest; + + private GoogleSheetPermissionService googleSheetPermissionService; + + @BeforeEach + void setUpGoogleSheetPermissionService() { + googleSheetPermissionService = new GoogleSheetPermissionService( + serviceAccountCredentials, + googleDriveService, + googleSheetsConfig, + userOAuthAccountRepository + ); + } + + @Test + @DisplayName("returns false when the requester has no Google Drive OAuth account") + void tryGrantServiceAccountWriterAccessReturnsFalseWhenOAuthAccountIsMissing() { + given(userOAuthAccountRepository.findByUserIdAndProvider(REQUESTER_ID, Provider.GOOGLE)) + .willReturn(Optional.empty()); + + boolean granted = googleSheetPermissionService.tryGrantServiceAccountWriterAccess( + REQUESTER_ID, + FILE_ID + ); + + assertThat(granted).isFalse(); + verify(userDriveService, never()).files(); + verify(userDriveService, never()).permissions(); + } + + @Test + @DisplayName("returns true without creating when the service account already has writer access") + void tryGrantServiceAccountWriterAccessReturnsTrueWhenPermissionAlreadyExists() + throws IOException, GeneralSecurityException { + mockConnectedDriveAccount(); + given(permissions.list(FILE_ID)).willReturn(listRequest); + given(listRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(listRequest); + given(listRequest.setSupportsAllDrives(true)).willReturn(listRequest); + given(listRequest.execute()).willReturn(permissionList(permission("perm-1", "writer"))); + + boolean granted = googleSheetPermissionService.tryGrantServiceAccountWriterAccess( + REQUESTER_ID, + FILE_ID + ); + + assertThat(granted).isTrue(); + verify(permissions, never()).create(eq(FILE_ID), any(Permission.class)); + verify(permissions, never()).update(eq(FILE_ID), eq("perm-1"), any(Permission.class)); + } + + @Test + @DisplayName("finds existing permission across paged Drive permission results") + void tryGrantServiceAccountWriterAccessFindsPermissionAcrossPages() + throws IOException, GeneralSecurityException { + mockConnectedDriveAccount(); + given(permissions.list(FILE_ID)).willReturn(listRequest, nextPageListRequest); + given(listRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(listRequest); + given(listRequest.setSupportsAllDrives(true)).willReturn(listRequest); + given(listRequest.execute()).willReturn( + new PermissionList().setPermissions(List.of()).setNextPageToken("next-page") + ); + given(nextPageListRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(nextPageListRequest); + given(nextPageListRequest.setSupportsAllDrives(true)).willReturn(nextPageListRequest); + given(nextPageListRequest.setPageToken("next-page")).willReturn(nextPageListRequest); + given(nextPageListRequest.execute()).willReturn(permissionList(permission("perm-1", "writer"))); + + boolean granted = googleSheetPermissionService.tryGrantServiceAccountWriterAccess( + REQUESTER_ID, + FILE_ID + ); + + assertThat(granted).isTrue(); + verify(permissions, never()).create(eq(FILE_ID), any(Permission.class)); + } + + @Test + @DisplayName("returns true when create fails but the permission is visible on re-check") + void tryGrantServiceAccountWriterAccessReturnsTrueAfterConcurrentGrant() + throws IOException, GeneralSecurityException { + mockConnectedDriveAccount(); + Drive.Permissions.List initialListRequest = mock(Drive.Permissions.List.class); + Drive.Permissions.List applyListRequest = mock(Drive.Permissions.List.class); + Drive.Permissions.List recheckListRequest = mock(Drive.Permissions.List.class); + + given(permissions.list(FILE_ID)).willReturn(initialListRequest, applyListRequest, recheckListRequest); + given(initialListRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(initialListRequest); + given(initialListRequest.setSupportsAllDrives(true)).willReturn(initialListRequest); + given(applyListRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(applyListRequest); + given(applyListRequest.setSupportsAllDrives(true)).willReturn(applyListRequest); + given(recheckListRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(recheckListRequest); + given(recheckListRequest.setSupportsAllDrives(true)).willReturn(recheckListRequest); + given(initialListRequest.execute()).willReturn(permissionList()); + given(applyListRequest.execute()).willReturn(permissionList()); + given(recheckListRequest.execute()).willReturn(permissionList(permission("perm-1", "writer"))); + given(permissions.create(eq(FILE_ID), any(Permission.class))).willReturn(createRequest); + given(createRequest.setSendNotificationEmail(false)).willReturn(createRequest); + given(createRequest.setSupportsAllDrives(true)).willReturn(createRequest); + given(createRequest.execute()).willThrow(new IOException("already granted")); + + boolean granted = googleSheetPermissionService.tryGrantServiceAccountWriterAccess( + REQUESTER_ID, + FILE_ID + ); + + assertThat(granted).isTrue(); + verify(permissions).create(eq(FILE_ID), any(Permission.class)); + } + + @Test + @DisplayName("returns true when an existing permission needs to be upgraded to writer") + void tryGrantServiceAccountWriterAccessUpgradesExistingPermission() + throws IOException, GeneralSecurityException { + mockConnectedDriveAccount(); + given(permissions.list(FILE_ID)).willReturn(listRequest); + given(listRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(listRequest); + given(listRequest.setSupportsAllDrives(true)).willReturn(listRequest); + given(listRequest.execute()).willReturn(permissionList(permission("perm-x", "reader"))); + given(permissions.update(eq(FILE_ID), eq("perm-x"), any(Permission.class))).willReturn(updateRequest); + given(updateRequest.setSupportsAllDrives(true)).willReturn(updateRequest); + given(updateRequest.execute()).willReturn(permission("perm-x", "writer")); + + boolean granted = googleSheetPermissionService.tryGrantServiceAccountWriterAccess( + REQUESTER_ID, + FILE_ID + ); + + assertThat(granted).isTrue(); + verify(permissions).update(eq(FILE_ID), eq("perm-x"), any(Permission.class)); + } + + @Test + @DisplayName("returns false when Google Drive auth fails during permission lookup") + void tryGrantServiceAccountWriterAccessReturnsFalseWhenAuthFails() + throws IOException, GeneralSecurityException { + mockConnectedDriveAccount(); + given(permissions.list(FILE_ID)).willReturn(listRequest); + given(listRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(listRequest); + given(listRequest.setSupportsAllDrives(true)).willReturn(listRequest); + given(listRequest.execute()).willThrow(googleException(401, "authError")); + + boolean granted = googleSheetPermissionService.tryGrantServiceAccountWriterAccess( + REQUESTER_ID, + FILE_ID + ); + + assertThat(granted).isFalse(); + } + + @Test + @DisplayName("returns false when Google Drive reports access denied while listing permissions") + void tryGrantServiceAccountWriterAccessReturnsFalseWhenAccessIsDenied() + throws IOException, GeneralSecurityException { + mockConnectedDriveAccount(); + given(permissions.list(FILE_ID)).willReturn(listRequest); + given(listRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(listRequest); + given(listRequest.setSupportsAllDrives(true)).willReturn(listRequest); + given(listRequest.execute()).willThrow(googleException(403, "forbidden")); + + boolean granted = googleSheetPermissionService.tryGrantServiceAccountWriterAccess( + REQUESTER_ID, + FILE_ID + ); + + assertThat(granted).isFalse(); + } + + @Test + @DisplayName("returns false when Google returns invalid_grant") + void tryGrantServiceAccountWriterAccessReturnsFalseWhenInvalidGrantOccurs() + throws IOException, GeneralSecurityException { + mockConnectedDriveAccount(); + given(permissions.list(FILE_ID)).willReturn(listRequest); + given(listRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(listRequest); + given(listRequest.setSupportsAllDrives(true)).willReturn(listRequest); + given(listRequest.execute()).willThrow(new IOException( + "token refresh failed", + httpResponseException( + 400, + "{\"error\":\"invalid_grant\",\"error_description\":\"Bad Request\"}" + ) + )); + + boolean granted = googleSheetPermissionService.tryGrantServiceAccountWriterAccess( + REQUESTER_ID, + FILE_ID + ); + + assertThat(granted).isFalse(); + } + + @Test + @DisplayName("요청자 계정이 시트 접근 권한이 없으면 forbidden 예외를 던진다") + void validateRequesterAccessAndTryGrantServiceAccountWriterAccessThrowsWhenRequesterCannotAccessSpreadsheet() + throws IOException, GeneralSecurityException { + given(userOAuthAccountRepository.findByUserIdAndProvider(REQUESTER_ID, Provider.GOOGLE)) + .willReturn(Optional.of(userOAuthAccount)); + given(userOAuthAccount.getGoogleDriveRefreshToken()).willReturn(REFRESH_TOKEN); + given(googleSheetsConfig.buildUserDriveService(REFRESH_TOKEN)).willReturn(userDriveService); + given(userDriveService.files()).willReturn(files); + given(files.get(FILE_ID)).willReturn(getFileRequest); + given(getFileRequest.setFields("id")).willReturn(getFileRequest); + given(getFileRequest.setSupportsAllDrives(true)).willReturn(getFileRequest); + given(getFileRequest.execute()).willThrow(googleException(403, "forbidden")); + + assertThatThrownBy(() -> + googleSheetPermissionService.validateRequesterAccessAndTryGrantServiceAccountWriterAccess( + REQUESTER_ID, + FILE_ID + ) + ) + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ApiResponseCode.FORBIDDEN_GOOGLE_SHEET_ACCESS); + } + + @Test + @DisplayName("요청자 계정이 시트에 접근 가능하면 서비스 계정 권한 부여까지 진행한다") + void validateRequesterAccessAndTryGrantServiceAccountWriterAccessSucceedsWhenRequesterCanAccessSpreadsheet() + throws IOException, GeneralSecurityException { + mockConnectedDriveAccount(); + given(userDriveService.files()).willReturn(files); + given(files.get(FILE_ID)).willReturn(getFileRequest); + given(getFileRequest.setFields("id")).willReturn(getFileRequest); + given(getFileRequest.setSupportsAllDrives(true)).willReturn(getFileRequest); + given(getFileRequest.execute()).willReturn(new File().setId(FILE_ID)); + given(permissions.list(FILE_ID)).willReturn(listRequest); + given(listRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(listRequest); + given(listRequest.setSupportsAllDrives(true)).willReturn(listRequest); + given(listRequest.execute()).willReturn(permissionList(permission("perm-1", "writer"))); + + googleSheetPermissionService.validateRequesterAccessAndTryGrantServiceAccountWriterAccess( + REQUESTER_ID, + FILE_ID + ); + + verify(files).get(FILE_ID); + verify(permissions, never()).create(eq(FILE_ID), any(Permission.class)); + } + + @Test + @DisplayName("서비스 계정 권한 부여가 실패해도 이미 접근 가능하면 가져오기를 계속 허용한다") + void validateRequesterAccessAndTryGrantServiceAccountWriterAccessContinuesWhenServiceAccountAlreadyHasAccess() + throws IOException, GeneralSecurityException { + mockConnectedDriveAccount(); + given(userDriveService.files()).willReturn(files); + given(files.get(FILE_ID)).willReturn(getFileRequest); + given(getFileRequest.setFields("id")).willReturn(getFileRequest); + given(getFileRequest.setSupportsAllDrives(true)).willReturn(getFileRequest); + given(getFileRequest.execute()).willReturn(new File().setId(FILE_ID)); + given(permissions.list(FILE_ID)).willReturn(listRequest); + given(listRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(listRequest); + given(listRequest.setSupportsAllDrives(true)).willReturn(listRequest); + given(listRequest.execute()).willThrow(googleException(403, "forbidden")); + given(googleDriveService.files()).willReturn(serviceAccountFiles); + given(serviceAccountFiles.get(FILE_ID)).willReturn(serviceAccountGetFileRequest); + given(serviceAccountGetFileRequest.setFields("id")).willReturn(serviceAccountGetFileRequest); + given(serviceAccountGetFileRequest.setSupportsAllDrives(true)).willReturn(serviceAccountGetFileRequest); + given(serviceAccountGetFileRequest.execute()).willReturn(new File().setId(FILE_ID)); + + googleSheetPermissionService.validateRequesterAccessAndTryGrantServiceAccountWriterAccess( + REQUESTER_ID, + FILE_ID + ); + + verify(serviceAccountFiles).get(FILE_ID); + } + + @Test + @DisplayName("서비스 계정 권한 부여가 실패하고 실제 접근도 불가하면 forbidden 예외를 던진다") + void validateRequesterAccessAndTryGrantServiceAccountWriterAccessThrowsWhenServiceAccountStillCannotAccess() + throws IOException, GeneralSecurityException { + mockConnectedDriveAccount(); + given(userDriveService.files()).willReturn(files); + given(files.get(FILE_ID)).willReturn(getFileRequest); + given(getFileRequest.setFields("id")).willReturn(getFileRequest); + given(getFileRequest.setSupportsAllDrives(true)).willReturn(getFileRequest); + given(getFileRequest.execute()).willReturn(new File().setId(FILE_ID)); + given(permissions.list(FILE_ID)).willReturn(listRequest); + given(listRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(listRequest); + given(listRequest.setSupportsAllDrives(true)).willReturn(listRequest); + given(listRequest.execute()).willThrow(googleException(403, "forbidden")); + given(googleDriveService.files()).willReturn(serviceAccountFiles); + given(serviceAccountFiles.get(FILE_ID)).willReturn(serviceAccountGetFileRequest); + given(serviceAccountGetFileRequest.setFields("id")).willReturn(serviceAccountGetFileRequest); + given(serviceAccountGetFileRequest.setSupportsAllDrives(true)).willReturn(serviceAccountGetFileRequest); + given(serviceAccountGetFileRequest.execute()).willThrow(googleException(404, "notFound")); + + assertThatThrownBy(() -> + googleSheetPermissionService.validateRequesterAccessAndTryGrantServiceAccountWriterAccess( + REQUESTER_ID, + FILE_ID + ) + ) + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ApiResponseCode.FORBIDDEN_GOOGLE_SHEET_ACCESS); + } + + @Test + @DisplayName("Drive OAuth 계정이 없으면 요청자 접근 검증을 거부한다") + void validateRequesterAccessAndTryGrantServiceAccountWriterAccessThrowsWhenOAuthAccountIsMissing() + throws IOException, GeneralSecurityException { + given(userOAuthAccountRepository.findByUserIdAndProvider(REQUESTER_ID, Provider.GOOGLE)) + .willReturn(Optional.empty()); + + assertThatThrownBy(() -> + googleSheetPermissionService.validateRequesterAccessAndTryGrantServiceAccountWriterAccess( + REQUESTER_ID, + FILE_ID + ) + ) + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ApiResponseCode.NOT_FOUND_GOOGLE_DRIVE_AUTH); + verify(userDriveService, never()).files(); + verify(userDriveService, never()).permissions(); + verify(googleSheetsConfig, never()).buildUserDriveService(any()); + } + + @Test + @DisplayName("Drive OAuth refresh token이 비어 있으면 요청자 접근 검증을 거부한다") + void validateRequesterAccessAndTryGrantServiceAccountWriterAccessThrowsWhenRefreshTokenIsBlank() + throws IOException, GeneralSecurityException { + given(userOAuthAccountRepository.findByUserIdAndProvider(REQUESTER_ID, Provider.GOOGLE)) + .willReturn(Optional.of(userOAuthAccount)); + given(userOAuthAccount.getGoogleDriveRefreshToken()).willReturn(" "); + + assertThatThrownBy(() -> + googleSheetPermissionService.validateRequesterAccessAndTryGrantServiceAccountWriterAccess( + REQUESTER_ID, + FILE_ID + ) + ) + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ApiResponseCode.NOT_FOUND_GOOGLE_DRIVE_AUTH); + verify(userDriveService, never()).files(); + verify(userDriveService, never()).permissions(); + verify(googleSheetsConfig, never()).buildUserDriveService(any()); + } + + @Test + @DisplayName("요청자 Drive 인증이 만료되면 invalid Google Drive auth 예외를 던진다") + void validateRequesterAccessAndTryGrantServiceAccountWriterAccessThrowsWhenAuthFailureOccurs() + throws IOException, GeneralSecurityException { + given(userOAuthAccountRepository.findByUserIdAndProvider(REQUESTER_ID, Provider.GOOGLE)) + .willReturn(Optional.of(userOAuthAccount)); + given(userOAuthAccount.getGoogleDriveRefreshToken()).willReturn(REFRESH_TOKEN); + given(googleSheetsConfig.buildUserDriveService(REFRESH_TOKEN)).willReturn(userDriveService); + given(userDriveService.files()).willReturn(files); + given(files.get(FILE_ID)).willReturn(getFileRequest); + given(getFileRequest.setFields("id")).willReturn(getFileRequest); + given(getFileRequest.setSupportsAllDrives(true)).willReturn(getFileRequest); + given(getFileRequest.execute()).willThrow(googleException(401, "authError")); + + assertThatThrownBy(() -> + googleSheetPermissionService.validateRequesterAccessAndTryGrantServiceAccountWriterAccess( + REQUESTER_ID, + FILE_ID + ) + ) + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ApiResponseCode.INVALID_GOOGLE_DRIVE_AUTH); + } + + private void mockConnectedDriveAccount() throws IOException, GeneralSecurityException { + given(userOAuthAccountRepository.findByUserIdAndProvider(REQUESTER_ID, Provider.GOOGLE)) + .willReturn(Optional.of(userOAuthAccount)); + given(userOAuthAccount.getGoogleDriveRefreshToken()).willReturn(REFRESH_TOKEN); + given(googleSheetsConfig.buildUserDriveService(REFRESH_TOKEN)).willReturn(userDriveService); + given(serviceAccountCredentials.getClientEmail()).willReturn(SERVICE_ACCOUNT_EMAIL); + given(userDriveService.permissions()).willReturn(permissions); + } + + private Permission permission(String id, String role) { + return new Permission() + .setId(id) + .setType("user") + .setEmailAddress(SERVICE_ACCOUNT_EMAIL) + .setRole(role); + } + + private PermissionList permissionList(Permission... permissions) { + return new PermissionList().setPermissions(List.of(permissions)); + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/club/service/SheetImportServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/club/service/SheetImportServiceTest.java new file mode 100644 index 000000000..779981d23 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/club/service/SheetImportServiceTest.java @@ -0,0 +1,216 @@ +package gg.agit.konect.unit.domain.club.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anySet; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verifyNoInteractions; + +import java.io.IOException; +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.springframework.transaction.PlatformTransactionManager; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.api.services.sheets.v4.Sheets; +import com.google.api.services.sheets.v4.model.ValueRange; + +import gg.agit.konect.domain.chat.service.ChatRoomMembershipService; +import gg.agit.konect.domain.club.dto.SheetImportConfirmRequest; +import gg.agit.konect.domain.club.dto.SheetImportPreviewResponse; +import gg.agit.konect.domain.club.dto.SheetImportResponse; +import gg.agit.konect.domain.club.enums.ClubPosition; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.model.SheetColumnMapping; +import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.club.repository.ClubPreMemberRepository; +import gg.agit.konect.domain.club.repository.ClubRepository; +import gg.agit.konect.domain.club.service.ClubPermissionValidator; +import gg.agit.konect.domain.club.service.SheetHeaderMapper; +import gg.agit.konect.domain.club.service.SheetImportService; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.support.ServiceTestSupport; +import gg.agit.konect.support.fixture.ClubFixture; +import gg.agit.konect.support.fixture.UniversityFixture; +import gg.agit.konect.support.fixture.UserFixture; + +class SheetImportServiceTest extends ServiceTestSupport { + + private static final Integer CLUB_ID = 1; + private static final Integer REQUESTER_ID = 2; + private static final String SPREADSHEET_ID = "sheet-id"; + + @Mock + private Sheets googleSheetsService; + + @Mock + private Sheets.Spreadsheets spreadsheets; + + @Mock + private Sheets.Spreadsheets.Values values; + + @Mock + private Sheets.Spreadsheets.Values.Get getRequest; + + @Mock + private SheetHeaderMapper sheetHeaderMapper; + + @Mock + private ClubRepository clubRepository; + + @Mock + private ClubPreMemberRepository clubPreMemberRepository; + + @Mock + private ClubMemberRepository clubMemberRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private ChatRoomMembershipService chatRoomMembershipService; + + @Mock + private ClubPermissionValidator clubPermissionValidator; + + @Mock + private PlatformTransactionManager transactionManager; + + @Spy + private ObjectMapper objectMapper = new ObjectMapper(); + + @InjectMocks + private SheetImportService sheetImportService; + + @Test + void previewPreMembersFromSheetReturnsDirectAndPreMembers() throws IOException { + Club club = ClubFixture.create(UniversityFixture.create()); + club.updateGoogleSheetId(SPREADSHEET_ID); + club.updateSheetColumnMapping(objectMapper.writeValueAsString( + SheetColumnMapping.defaultMapping().toMap() + )); + User directUser = UserFixture.createUser(club.getUniversity(), "Alex Kim", "2021232948"); + + given(clubRepository.getByIdWithUniversity(CLUB_ID)).willReturn(club); + given(clubMemberRepository.findStudentNumbersByClubId(CLUB_ID)).willReturn(Set.of()); + given(clubPreMemberRepository.findStudentNumberAndNameByClubId(CLUB_ID)) + .willReturn(List.of()); + given(clubMemberRepository.findUserIdsByClubId(CLUB_ID)).willReturn(List.of()); + given(userRepository.findAllByUniversityIdAndStudentNumberIn( + eq(club.getUniversity().getId()), + anySet() + )).willReturn(List.of(directUser)); + + given(googleSheetsService.spreadsheets()).willReturn(spreadsheets); + given(spreadsheets.values()).willReturn(values); + given(values.get(SPREADSHEET_ID, "A2:Z")).willReturn(getRequest); + given(getRequest.setValueRenderOption("FORMATTED_VALUE")).willReturn(getRequest); + given(getRequest.execute()).willReturn(new ValueRange().setValues(List.of( + List.of("Alex Kim", "2021232948", "", "010-1234-5678", ClubPosition.MANAGER.name()), + List.of("Dana Lee", "2021232949", "", "010-9999-8888", ClubPosition.MEMBER.name()) + ))); + + SheetImportPreviewResponse response = sheetImportService.previewPreMembersFromSheet( + CLUB_ID, + REQUESTER_ID + ); + + assertThat(response.previewCount()).isEqualTo(2); + assertThat(response.autoRegisteredCount()).isEqualTo(1); + assertThat(response.preRegisteredCount()).isEqualTo(1); + assertThat(response.members()) + .extracting(SheetImportPreviewResponse.PreviewMember::studentNumber) + .containsExactly("2021232948", "2021232949"); + assertThat(response.members()) + .extracting(SheetImportPreviewResponse.PreviewMember::isDirectMember) + .containsExactly(true, false); + assertThat(response.members()) + .extracting(SheetImportPreviewResponse.PreviewMember::enabled) + .containsExactly(true, true); + } + + @Test + void previewPreMembersFromSheetThrowsWhenSheetIsNotRegistered() { + Club club = ClubFixture.create(UniversityFixture.create()); + + given(clubRepository.getByIdWithUniversity(CLUB_ID)).willReturn(club); + + assertThatThrownBy(() -> sheetImportService.previewPreMembersFromSheet(CLUB_ID, REQUESTER_ID)) + .isInstanceOf(CustomException.class) + .extracting(exception -> ((CustomException)exception).getErrorCode()) + .isEqualTo(ApiResponseCode.CLUB_SHEET_ANALYSIS_REQUIRED); + + verifyNoInteractions(googleSheetsService, sheetHeaderMapper); + } + + @Test + void previewPreMembersFromSheetThrowsWhenSheetMappingIsMissing() { + Club club = ClubFixture.create(UniversityFixture.create()); + club.updateGoogleSheetId(SPREADSHEET_ID); + + given(clubRepository.getByIdWithUniversity(CLUB_ID)).willReturn(club); + + assertThatThrownBy(() -> sheetImportService.previewPreMembersFromSheet(CLUB_ID, REQUESTER_ID)) + .isInstanceOf(CustomException.class) + .extracting(exception -> ((CustomException)exception).getErrorCode()) + .isEqualTo(ApiResponseCode.CLUB_SHEET_ANALYSIS_REQUIRED); + + verifyNoInteractions(googleSheetsService, sheetHeaderMapper); + } + + @Test + void confirmImportPreMembersImportsOnlyEnabledMembers() { + Club club = ClubFixture.create(UniversityFixture.create()); + User directUser = UserFixture.createUser(club.getUniversity(), "Alex Kim", "2021232948"); + + given(clubRepository.getById(CLUB_ID)).willReturn(club); + given(clubMemberRepository.findStudentNumbersByClubId(CLUB_ID)).willReturn(Set.of()); + given(clubPreMemberRepository.findStudentNumberAndNameByClubId(CLUB_ID)) + .willReturn(List.of()); + given(clubMemberRepository.findUserIdsByClubId(CLUB_ID)).willReturn(List.of()); + given(userRepository.findAllByUniversityIdAndStudentNumberIn( + eq(club.getUniversity().getId()), + anySet() + )).willReturn(List.of(directUser)); + given(clubMemberRepository.saveAll(anyList())).willAnswer(invocation -> invocation.getArgument(0)); + + SheetImportResponse response = sheetImportService.confirmImportPreMembers( + CLUB_ID, + REQUESTER_ID, + List.of( + new SheetImportConfirmRequest.ConfirmMember( + "2021232948", + "Alex Kim", + ClubPosition.MANAGER, + true + ), + new SheetImportConfirmRequest.ConfirmMember( + "2021232949", + "Dana Lee", + ClubPosition.MEMBER, + false + ), + new SheetImportConfirmRequest.ConfirmMember( + "2021232950", + "Chris Park", + ClubPosition.MEMBER, + true + ) + ) + ); + + assertThat(response.importedCount()).isEqualTo(1); + assertThat(response.autoRegisteredCount()).isEqualTo(1); + verifyNoInteractions(googleSheetsService); + } +} diff --git a/src/test/java/gg/agit/konect/domain/club/service/SheetSyncExecutorTest.java b/src/test/java/gg/agit/konect/unit/domain/club/service/SheetSyncExecutorTest.java similarity index 98% rename from src/test/java/gg/agit/konect/domain/club/service/SheetSyncExecutorTest.java rename to src/test/java/gg/agit/konect/unit/domain/club/service/SheetSyncExecutorTest.java index 7aa5115f6..32c72ff9f 100644 --- a/src/test/java/gg/agit/konect/domain/club/service/SheetSyncExecutorTest.java +++ b/src/test/java/gg/agit/konect/unit/domain/club/service/SheetSyncExecutorTest.java @@ -1,4 +1,4 @@ -package gg.agit.konect.domain.club.service; +package gg.agit.konect.unit.domain.club.service; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.argThat; @@ -34,6 +34,7 @@ import gg.agit.konect.domain.club.repository.ClubPreMemberRepository; import gg.agit.konect.domain.club.repository.ClubRepository; import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.club.service.SheetSyncExecutor; import gg.agit.konect.support.ServiceTestSupport; import gg.agit.konect.support.fixture.ClubFixture; import gg.agit.konect.support.fixture.ClubMemberFixture; diff --git a/src/test/java/gg/agit/konect/unit/domain/notification/service/NotificationInboxServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/notification/service/NotificationInboxServiceTest.java new file mode 100644 index 000000000..e213dbb56 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/notification/service/NotificationInboxServiceTest.java @@ -0,0 +1,315 @@ +package gg.agit.konect.unit.domain.notification.service; + +import static org.assertj.core.api.Assertions.assertThatCode; +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.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import gg.agit.konect.domain.notification.dto.NotificationInboxResponse; +import gg.agit.konect.domain.notification.enums.NotificationInboxType; +import gg.agit.konect.domain.notification.model.NotificationInbox; +import gg.agit.konect.domain.notification.repository.NotificationInboxRepository; +import gg.agit.konect.domain.notification.service.NotificationInboxService; +import gg.agit.konect.domain.notification.service.NotificationInboxSseService; +import gg.agit.konect.domain.university.model.University; +import gg.agit.konect.domain.user.enums.UserRole; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.support.ServiceTestSupport; +import gg.agit.konect.support.fixture.UniversityFixture; + +class NotificationInboxServiceTest extends ServiceTestSupport { + + @Mock + private NotificationInboxRepository notificationInboxRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private NotificationInboxSseService notificationInboxSseService; + + @InjectMocks + private NotificationInboxService notificationInboxService; + + @Test + @DisplayName("saveAll은 조회된 사용자에게만 알림을 생성한다") + void saveAllOnlyCreatesForResolvedUsers() { + // given + University university = UniversityFixture.create(); + User user1 = createUser(university, 1, "유저1", "2021136001"); + User user2 = createUser(university, 2, "유저2", "2021136002"); + given(userRepository.findAllByIdIn(List.of(1, 2, 3))).willReturn(List.of(user1, user2)); + given(notificationInboxRepository.saveAll(any())).willAnswer(invocation -> invocation.getArgument(0)); + + // when + List result = notificationInboxService.saveAll( + List.of(1, 2, 3), + NotificationInboxType.CLUB_APPLICATION_APPROVED, + "제목", + "본문", + "/clubs/1" + ); + + // then + verify(notificationInboxRepository).saveAll(any()); + assertThatCode(() -> result.get(0).getTitle()).doesNotThrowAnyException(); + org.assertj.core.api.Assertions.assertThat(result).hasSize(2); + org.assertj.core.api.Assertions.assertThat(result) + .extracting(inbox -> inbox.getUser().getStudentNumber()) + .containsExactly("2021136001", "2021136002"); + } + + @Test + @DisplayName("sendSseBatch는 일부 사용자 전송 실패가 있어도 나머지 전송을 계속한다") + void sendSseBatchContinuesWhenOneSendFails() { + // given + University university = UniversityFixture.create(); + User user1 = createUser(university, 1, "유저1", "2021136001"); + User user2 = createUser(university, 2, "유저2", "2021136002"); + NotificationInbox inbox1 = NotificationInbox.of( + user1, + NotificationInboxType.CLUB_APPLICATION_APPROVED, + "제목1", + "본문1", + "/clubs/1" + ); + NotificationInbox inbox2 = NotificationInbox.of( + user2, + NotificationInboxType.CLUB_APPLICATION_REJECTED, + "제목2", + "본문2", + "/clubs/2" + ); + + doThrow(new RuntimeException("sse failure")) + .when(notificationInboxSseService) + .send(eq(user1.getId()), any(NotificationInboxResponse.class)); + + // when + assertThatCode(() -> notificationInboxService.sendSseBatch(List.of(inbox1, inbox2))) + .doesNotThrowAnyException(); + + // then + verify(notificationInboxSseService, times(1)).send(eq(user1.getId()), any(NotificationInboxResponse.class)); + verify(notificationInboxSseService, times(1)).send(eq(user2.getId()), any(NotificationInboxResponse.class)); + } + + @Test + @DisplayName("saveAll은 대상 사용자가 없으면 저장을 시도하지 않는다") + void saveAllSkipsWhenUserIdsEmpty() { + // when + List result = notificationInboxService.saveAll( + List.of(), + NotificationInboxType.CLUB_APPLICATION_APPROVED, + "제목", + "본문", + "/clubs/1" + ); + + // then + org.assertj.core.api.Assertions.assertThat(result).isEmpty(); + verify(userRepository, never()).findAllByIdIn(any()); + verify(notificationInboxRepository, never()).saveAll(any()); + } + + @Test + @DisplayName("save는 단일 알림을 생성한다") + void saveCreatesSingleNotification() { + // given + University university = UniversityFixture.create(); + User user = createUser(university, 1, "유저1", "2021136001"); + NotificationInbox inbox = NotificationInbox.of( + user, + NotificationInboxType.CLUB_APPLICATION_APPROVED, + "제목", + "본문", + "/clubs/1" + ); + given(userRepository.getById(1)).willReturn(user); + given(notificationInboxRepository.save(any(NotificationInbox.class))).willReturn(inbox); + + // when + NotificationInbox result = notificationInboxService.save( + 1, + NotificationInboxType.CLUB_APPLICATION_APPROVED, + "제목", + "본문", + "/clubs/1" + ); + + // then + verify(notificationInboxRepository).save(any(NotificationInbox.class)); + org.assertj.core.api.Assertions.assertThat(result).isNotNull(); + } + + @Test + @DisplayName("sendSse는 SSE 알림을 발송한다") + void sendSseSendsNotification() { + // given + University university = UniversityFixture.create(); + User user = createUser(university, 1, "유저1", "2021136001"); + NotificationInbox inbox = NotificationInbox.of( + user, + NotificationInboxType.CLUB_APPLICATION_APPROVED, + "제목", + "본문", + "/clubs/1" + ); + NotificationInboxResponse response = NotificationInboxResponse.from(inbox); + doNothing().when(notificationInboxSseService).send(eq(1), any(NotificationInboxResponse.class)); + + // when + assertThatCode(() -> notificationInboxService.sendSse(1, response)) + .doesNotThrowAnyException(); + + // then + verify(notificationInboxSseService).send(eq(1), any(NotificationInboxResponse.class)); + } + + @Test + @DisplayName("getMyInboxes는 빈 결과를 반환한다") + void getMyInboxesReturnsEmptyResult() { + // given + org.springframework.data.domain.Page emptyPage = org.springframework.data.domain.Page.empty(); + given(notificationInboxRepository.findAllByUserIdAndTypeNotInOrderByCreatedAtDescIdDesc( + eq(1), + any(Set.class), + any(org.springframework.data.domain.PageRequest.class) + )).willReturn(emptyPage); + + // when + gg.agit.konect.domain.notification.dto.NotificationInboxesResponse result = + notificationInboxService.getMyInboxes(1, 1); + + // then + org.assertj.core.api.Assertions.assertThat(result.notifications()).isEmpty(); + } + + @Test + @DisplayName("getUnreadCount는 카운트 0을 반환한다") + void getUnreadCountReturnsZero() { + // given + given(notificationInboxRepository.countByUserIdAndIsReadFalseAndTypeNotIn( + eq(1), + any(Set.class) + )).willReturn(0L); + + // when + gg.agit.konect.domain.notification.dto.NotificationInboxUnreadCountResponse result = + notificationInboxService.getUnreadCount(1); + + // then + org.assertj.core.api.Assertions.assertThat(result.unreadCount()).isEqualTo(0); + } + + @Test + @DisplayName("markAsRead는 정상 동작한다") + void markAsReadWorksNormally() { + // given + University university = UniversityFixture.create(); + User user = createUser(university, 1, "유저1", "2021136001"); + NotificationInbox inbox = NotificationInbox.of( + user, + NotificationInboxType.CLUB_APPLICATION_APPROVED, + "제목", + "본문", + "/clubs/1" + ); + given(notificationInboxRepository.getByIdAndUserId(1, 1)).willReturn(inbox); + + // when + assertThatCode(() -> notificationInboxService.markAsRead(1, 1)) + .doesNotThrowAnyException(); + + // then + org.assertj.core.api.Assertions.assertThat(inbox.getIsRead()).isTrue(); + } + + @Test + @DisplayName("markAllAsRead는 알림이 없는 경우에도 에러 없이 동작한다") + void markAllAsReadWorksWithNoNotifications() { + // given + doNothing().when(notificationInboxRepository).markAllAsReadByUserIdAndTypeNotIn( + eq(1), + any(Set.class) + ); + + // when + assertThatCode(() -> notificationInboxService.markAllAsRead(1)) + .doesNotThrowAnyException(); + + // then + verify(notificationInboxRepository).markAllAsReadByUserIdAndTypeNotIn(eq(1), any(Set.class)); + } + + @Test + @DisplayName("saveAll은 일부 사용자만 존재하면 존재하는 사용자에게만 알림을 생성한다") + void saveAllCreatesNotificationsOnlyForExistingUsers() { + // given + University university = UniversityFixture.create(); + User user1 = createUser(university, 1, "유저1", "2021136001"); + User user3 = createUser(university, 3, "유저3", "2021136003"); + given(userRepository.findAllByIdIn(List.of(1, 2, 3))).willReturn(List.of(user1, user3)); + given(notificationInboxRepository.saveAll(any())).willAnswer(invocation -> invocation.getArgument(0)); + + // when + List result = notificationInboxService.saveAll( + List.of(1, 2, 3), + NotificationInboxType.CLUB_APPLICATION_APPROVED, + "제목", + "본문", + "/clubs/1" + ); + + // then + verify(notificationInboxRepository).saveAll(any()); + org.assertj.core.api.Assertions.assertThat(result).hasSize(2); + org.assertj.core.api.Assertions.assertThat(result) + .extracting(inbox -> inbox.getUser().getId()) + .containsExactly(1, 3); + } + + @Test + @DisplayName("markAsRead는 다른 사용자의 알림에 대해 예외를 발생시킨다") + void markAsReadThrowsExceptionForOtherUsersNotification() { + // given + University university = UniversityFixture.create(); + createUser(university, 1, "유저1", "2021136001"); + createUser(university, 2, "유저2", "2021136002"); + given(notificationInboxRepository.getByIdAndUserId(1, 2)).willThrow( + new org.springframework.dao.EmptyResultDataAccessException(1) + ); + + // when & then + assertThatThrownBy(() -> notificationInboxService.markAsRead(2, 1)) + .isInstanceOf(org.springframework.dao.EmptyResultDataAccessException.class); + } + + private User createUser(University university, Integer id, String name, String studentNumber) { + return User.builder() + .id(id) + .university(university) + .email(studentNumber + "@koreatech.ac.kr") + .name(name) + .studentNumber(studentNumber) + .role(UserRole.USER) + .isMarketingAgreement(true) + .imageUrl("https://example.com/profile.png") + .build(); + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/notification/service/NotificationInboxSseServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/notification/service/NotificationInboxSseServiceTest.java new file mode 100644 index 000000000..f5aa81969 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/notification/service/NotificationInboxSseServiceTest.java @@ -0,0 +1,134 @@ +package gg.agit.konect.unit.domain.notification.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +import java.lang.reflect.Field; +import java.util.Map; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import gg.agit.konect.domain.notification.dto.NotificationInboxResponse; +import gg.agit.konect.domain.notification.service.NotificationInboxSseService; +import gg.agit.konect.support.ServiceTestSupport; + +class NotificationInboxSseServiceTest extends ServiceTestSupport { + + private final NotificationInboxSseService notificationInboxSseService = new NotificationInboxSseService(); + + @Test + @DisplayName("같은 사용자가 재구독한 뒤 이전 emitter가 완료되어도 현재 구독은 유지된다") + void subscribeReplacesEmitterWithoutRemovingNewOne() throws Exception { + // given + SseEmitter firstEmitter = notificationInboxSseService.subscribe(1); + SseEmitter secondEmitter = notificationInboxSseService.subscribe(1); + + // when + firstEmitter.complete(); + + // then + Map emitters = emitters(); + assertThat(emitters).hasSize(1); + assertThat(emitters.get(1)).isSameAs(secondEmitter); + } + + @Test + @DisplayName("send는 null userId에 대해 NullPointerException을 발생시킨다") + void sendThrowsExceptionForNullUserId() { + // given + NotificationInboxResponse response = createMockNotificationResponse(); + + // when & then + org.assertj.core.api.Assertions.assertThatThrownBy(() -> notificationInboxSseService.send(null, response)) + .isInstanceOf(NullPointerException.class); + } + + @Test + @DisplayName("send는 emitter가 없는 경우(구독하지 않은 사용자) 에러 없이 처리한다") + void sendHandlesNonSubscribedUser() { + // given + NotificationInboxResponse response = createMockNotificationResponse(); + + // when & then + assertThatCode(() -> notificationInboxSseService.send(999, response)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("subscribe는 기존 emitter를 교체한다") + void subscribeReplacesExistingEmitter() throws Exception { + // given + SseEmitter firstEmitter = notificationInboxSseService.subscribe(1); + Map emittersBefore = emitters(); + + // when + SseEmitter secondEmitter = notificationInboxSseService.subscribe(1); + Map emittersAfter = emitters(); + + // then + assertThat(emittersBefore).hasSize(1); + assertThat(emittersAfter).hasSize(1); + assertThat(emittersAfter.get(1)).isNotSameAs(firstEmitter); + assertThat(emittersAfter.get(1)).isSameAs(secondEmitter); + } + + private NotificationInboxResponse createMockNotificationResponse() { + return new NotificationInboxResponse( + 1, + gg.agit.konect.domain.notification.enums.NotificationInboxType.CLUB_APPLICATION_APPROVED, + "title", + "body", + "path", + false, + null + ); + } + + @Test + @DisplayName("subscribe는 기존 emitter가 있으면 완료 후 교체한다") + void subscribeCompletesPreviousEmitterBeforeReplacement() throws Exception { + // given + SseEmitter firstEmitter = notificationInboxSseService.subscribe(1); + Map emittersBefore = emitters(); + + // when + SseEmitter secondEmitter = notificationInboxSseService.subscribe(1); + Map emittersAfter = emitters(); + + // then + assertThat(emittersBefore).hasSize(1); + assertThat(emittersAfter).hasSize(1); + assertThat(emittersAfter.get(1)).isNotSameAs(firstEmitter); + assertThat(emittersAfter.get(1)).isSameAs(secondEmitter); + + // 이전 emitter가 완료되었는지 확인 + org.assertj.core.api.Assertions.assertThatThrownBy(() -> firstEmitter.send(SseEmitter.event().data("test"))) + .isInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("send는 IOException 발생 시 emitter를 제거한다") + void sendRemovesEmitterOnIOException() { + // given + notificationInboxSseService.subscribe(1); + NotificationInboxResponse response = createMockNotificationResponse(); + + // when + // emitter가 존재하는 상태에서 전송 시도 + notificationInboxSseService.send(1, response); + + // then + // 메서드가 정상적으로 동작하는지 확인 + assertThatCode(() -> notificationInboxSseService.send(1, response)) + .doesNotThrowAnyException(); + } + + @SuppressWarnings("unchecked") + private Map emitters() throws Exception { + Field field = NotificationInboxSseService.class.getDeclaredField("emitters"); + field.setAccessible(true); + return (Map)field.get(notificationInboxSseService); + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/notification/service/NotificationServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/notification/service/NotificationServiceTest.java new file mode 100644 index 000000000..da8a16510 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/notification/service/NotificationServiceTest.java @@ -0,0 +1,780 @@ +package gg.agit.konect.unit.domain.notification.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +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; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import gg.agit.konect.domain.chat.service.ChatPresenceService; +import gg.agit.konect.domain.notification.dto.NotificationInboxResponse; +import gg.agit.konect.domain.notification.dto.NotificationTokenDeleteRequest; +import gg.agit.konect.domain.notification.dto.NotificationTokenRegisterRequest; +import gg.agit.konect.domain.notification.enums.NotificationInboxType; +import gg.agit.konect.domain.notification.enums.NotificationTargetType; +import gg.agit.konect.domain.notification.model.NotificationDeviceToken; +import gg.agit.konect.domain.notification.model.NotificationInbox; +import gg.agit.konect.domain.notification.repository.NotificationDeviceTokenRepository; +import gg.agit.konect.domain.notification.repository.NotificationMuteSettingRepository; +import gg.agit.konect.domain.notification.service.NotificationInboxService; +import gg.agit.konect.domain.notification.service.NotificationService; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.domain.notification.service.ExpoPushClient; +import gg.agit.konect.support.ServiceTestSupport; +import gg.agit.konect.support.fixture.UniversityFixture; + +class NotificationServiceTest extends ServiceTestSupport { + + private static final String VALID_TOKEN = "ExpoPushToken[valid-token]"; + + @Mock + private UserRepository userRepository; + + @Mock + private NotificationDeviceTokenRepository notificationDeviceTokenRepository; + + @Mock + private NotificationMuteSettingRepository notificationMuteSettingRepository; + + @Mock + private ChatPresenceService chatPresenceService; + + @Mock + private ExpoPushClient expoPushClient; + + @Mock + private NotificationInboxService notificationInboxService; + + @InjectMocks + private NotificationService notificationService; + + @Test + @DisplayName("registerToken은 기존 토큰이 없으면 새 엔티티를 저장한다") + void registerTokenSavesNewTokenWhenMissing() { + // given + User user = createUser(1, "2021136001"); + NotificationTokenRegisterRequest request = new NotificationTokenRegisterRequest(VALID_TOKEN); + given(userRepository.getById(1)).willReturn(user); + given(notificationDeviceTokenRepository.findByUserId(1)).willReturn(Optional.empty()); + + // when + notificationService.registerToken(1, request); + + // then + verify(notificationDeviceTokenRepository).save(argThat(token -> + token.getUser().equals(user) && token.getToken().equals(VALID_TOKEN) + )); + } + + @Test + @DisplayName("registerToken은 기존 토큰이 있으면 값을 갱신한다") + void registerTokenUpdatesExistingToken() { + // given + User user = createUser(1, "2021136001"); + NotificationDeviceToken existingToken = NotificationDeviceToken.of(user, "ExpoPushToken[old-token]"); + given(userRepository.getById(1)).willReturn(user); + given(notificationDeviceTokenRepository.findByUserId(1)).willReturn(Optional.of(existingToken)); + + // when + notificationService.registerToken(1, new NotificationTokenRegisterRequest(VALID_TOKEN)); + + // then + assertThat(existingToken.getToken()).isEqualTo(VALID_TOKEN); + verify(notificationDeviceTokenRepository, never()).save(any(NotificationDeviceToken.class)); + } + + @Test + @DisplayName("registerToken은 Expo 형식이 아닌 토큰을 거부한다") + void registerTokenRejectsInvalidExpoToken() { + // given + User user = createUser(1, "2021136001"); + given(userRepository.getById(1)).willReturn(user); + + // when & then + assertThatThrownBy( + () -> notificationService.registerToken(1, new NotificationTokenRegisterRequest("bad-token")) + ) + .isInstanceOf(CustomException.class) + .satisfies(exception -> assertThat(((CustomException)exception).getErrorCode()) + .isEqualTo(ApiResponseCode.INVALID_NOTIFICATION_TOKEN)); + verify(notificationDeviceTokenRepository, never()).findByUserId(any()); + } + + @Test + @DisplayName("deleteToken은 일치하는 토큰이 있을 때만 삭제한다") + void deleteTokenDeletesOnlyMatchingToken() { + // given + User user = createUser(1, "2021136001"); + NotificationDeviceToken token = NotificationDeviceToken.of(user, VALID_TOKEN); + given(notificationDeviceTokenRepository.findByUserIdAndToken(1, VALID_TOKEN)).willReturn(Optional.of(token)); + given(notificationDeviceTokenRepository.findByUserIdAndToken(1, "ExpoPushToken[missing]")) + .willReturn(Optional.empty()); + + // when + notificationService.deleteToken(1, new NotificationTokenDeleteRequest(VALID_TOKEN)); + notificationService.deleteToken(1, new NotificationTokenDeleteRequest("ExpoPushToken[missing]")); + + // then + verify(notificationDeviceTokenRepository).delete(token); + } + + @Test + @DisplayName("sendChatNotification은 사용자가 채팅방에 있으면 푸시를 생략한다") + void sendChatNotificationSkipsWhenUserAlreadyInRoom() { + // given + given(chatPresenceService.isUserInChatRoom(7, 3)).willReturn(true); + + // when + assertThatCode(() -> notificationService.sendChatNotification(3, 7, "보낸이", "메시지")) + .doesNotThrowAnyException(); + + // then + verify(notificationDeviceTokenRepository, never()).findTokensByUserId(any()); + verify(expoPushClient, never()).sendNotification(any(), any(), any(), any(), any()); + } + + @Test + @DisplayName("sendChatNotification은 뮤트되지 않은 사용자에게 잘린 미리보기 푸시를 보낸다") + void sendChatNotificationSendsTruncatedPreview() { + // given + String message = "😀".repeat(31); + String expectedPreview = "😀".repeat(30) + "..."; + given(chatPresenceService.isUserInChatRoom(7, 3)).willReturn(false); + given( + notificationMuteSettingRepository.findByTargetTypeAndTargetIdAndUserId( + NotificationTargetType.CHAT_ROOM, + 7, + 3 + ) + ).willReturn(Optional.empty()); + given(notificationDeviceTokenRepository.findTokensByUserId(3)).willReturn(List.of(VALID_TOKEN)); + + // when + notificationService.sendChatNotification(3, 7, "보낸이", message); + + // then + verify(expoPushClient).sendNotification( + eq(3), + eq(List.of(VALID_TOKEN)), + eq("보낸이"), + eq(expectedPreview), + eq(Map.of("path", "chats/7")) + ); + } + + @Test + @DisplayName("sendGroupChatNotification은 발신자·접속중·뮤트 사용자를 제외하고 전송한다") + void sendGroupChatNotificationFiltersRecipientsBeforeBatchSend() { + // given + String message = "안녕하세요 여러분 반갑습니다. " + + "이 메시지는 미리보기 길이를 초과하도록 충분히 깁니다!"; + given(chatPresenceService.findUsersInChatRoom(10, List.of(2, 3, 4, 5))).willReturn(Set.of(2)); + given(notificationMuteSettingRepository.findMutedUserIdsByTargetTypeAndTargetIdAndUserIds( + NotificationTargetType.CHAT_ROOM, + 10, + List.of(2, 3, 4, 5) + )).willReturn(Set.of(3)); + given(notificationDeviceTokenRepository.findTokensByUserIds(List.of(4, 5))) + .willReturn(List.of("ExpoPushToken[token-4]", "ExpoPushToken[token-5]")); + + // when + notificationService.sendGroupChatNotification( + 10, + 1, + "KONECT", + "홍길동", + message, + List.of(1, 2, 3, 4, 5) + ); + + // then + ArgumentCaptor> messagesCaptor = ArgumentCaptor.forClass(List.class); + verify(expoPushClient).sendBatchNotifications(messagesCaptor.capture()); + List messages = messagesCaptor.getValue(); + assertThat(messages).hasSize(2); + assertThat(messages) + .extracting(ExpoPushClient.ExpoPushMessage::to) + .containsExactly("ExpoPushToken[token-4]", "ExpoPushToken[token-5]"); + assertThat(messages) + .extracting(ExpoPushClient.ExpoPushMessage::title) + .containsOnly("KONECT"); + assertThat(messages) + .extracting(ExpoPushClient.ExpoPushMessage::body) + .allSatisfy(body -> { + assertThat(body).startsWith("홍길동: "); + assertThat(body).endsWith("..."); + }); + assertThat(messages) + .extracting(ExpoPushClient.ExpoPushMessage::data) + .containsOnly(Map.of("path", "chats/10")); + } + + @Test + @DisplayName("sendGroupChatNotification은 필터링 후 대상이 없으면 배치 전송을 생략한다") + void sendGroupChatNotificationSkipsWhenNoRecipientsRemain() { + // given + given(chatPresenceService.findUsersInChatRoom(10, List.of(2, 3))).willReturn(Set.of(2)); + given(notificationMuteSettingRepository.findMutedUserIdsByTargetTypeAndTargetIdAndUserIds( + NotificationTargetType.CHAT_ROOM, + 10, + List.of(2, 3) + )).willReturn(Set.of(3)); + + // when + assertThatCode(() -> notificationService.sendGroupChatNotification( + 10, + 1, + "KONECT", + "홍길동", + "메시지", + List.of(1, 2, 3) + )).doesNotThrowAnyException(); + + // then + verify(notificationDeviceTokenRepository, never()).findTokensByUserIds(any()); + verify(expoPushClient, never()).sendBatchNotifications(any()); + } + + @Test + @DisplayName("sendClubApplicationApprovedNotification은 인앱 알림, SSE, 푸시를 함께 보낸다") + void sendClubApplicationApprovedNotificationSendsInboxSseAndPush() { + // given + User user = createUser(3, "2021136003"); + NotificationInbox inbox = NotificationInbox.of( + user, + NotificationInboxType.CLUB_APPLICATION_APPROVED, + "KONECT", + "동아리 지원이 승인되었어요.", + "clubs/7" + ); + given(notificationInboxService.save( + 3, + NotificationInboxType.CLUB_APPLICATION_APPROVED, + "KONECT", + "동아리 지원이 승인되었어요.", + "clubs/7" + )).willReturn(inbox); + given(notificationDeviceTokenRepository.findTokensByUserId(3)).willReturn(List.of(VALID_TOKEN)); + + // when + notificationService.sendClubApplicationApprovedNotification(3, 7, "KONECT"); + + // then + ArgumentCaptor responseCaptor = + ArgumentCaptor.forClass(NotificationInboxResponse.class); + verify(notificationInboxService).sendSse(eq(3), responseCaptor.capture()); + NotificationInboxResponse response = responseCaptor.getValue(); + assertThat(response.type()).isEqualTo(NotificationInboxType.CLUB_APPLICATION_APPROVED); + assertThat(response.title()).isEqualTo("KONECT"); + assertThat(response.body()).isEqualTo("동아리 지원이 승인되었어요."); + assertThat(response.path()).isEqualTo("clubs/7"); + verify(expoPushClient).sendNotification( + eq(3), + eq(List.of(VALID_TOKEN)), + eq("KONECT"), + eq("동아리 지원이 승인되었어요."), + eq(Map.of("path", "clubs/7")) + ); + } + + @Test + @DisplayName("registerToken은 null 토큰 값에 대해 NullPointerException을 발생시킨다") + void registerTokenThrowsExceptionForNullToken() { + // when & then + assertThatThrownBy( + () -> notificationService.registerToken(1, new NotificationTokenRegisterRequest(null)) + ) + .isInstanceOf(NullPointerException.class); + } + + @Test + @DisplayName("registerToken은 빈 토큰 값에 대해 예외를 발생시킨다") + void registerTokenThrowsExceptionForEmptyToken() { + // when & then + assertThatThrownBy( + () -> notificationService.registerToken(1, new NotificationTokenRegisterRequest("")) + ) + .isInstanceOf(CustomException.class) + .satisfies(exception -> assertThat(((CustomException)exception).getErrorCode()) + .isEqualTo(ApiResponseCode.INVALID_NOTIFICATION_TOKEN)); + } + + @Test + @DisplayName("deleteToken은 토큰을 찾을 수 없는 경우 아무 동작도 하지 않는다") + void deleteTokenDoesNothingWhenTokenNotFound() { + // given + given(notificationDeviceTokenRepository.findByUserIdAndToken(1, VALID_TOKEN)) + .willReturn(Optional.empty()); + + // when + assertThatCode(() -> notificationService.deleteToken(1, new NotificationTokenDeleteRequest(VALID_TOKEN))) + .doesNotThrowAnyException(); + + // then + verify(notificationDeviceTokenRepository, never()).delete(any()); + } + + @Test + @DisplayName("sendChatNotification은 사용자가 음소거된 경우 알림을 발송하지 않는다") + void sendChatNotificationSkipsWhenUserMuted() { + // given + User user = createUser(3, "2021136003"); + gg.agit.konect.domain.notification.model.NotificationMuteSetting muteSetting = + gg.agit.konect.domain.notification.model.NotificationMuteSetting.of( + NotificationTargetType.CHAT_ROOM, + 7, + user, + true + ); + given(chatPresenceService.isUserInChatRoom(7, 3)).willReturn(false); + given( + notificationMuteSettingRepository.findByTargetTypeAndTargetIdAndUserId( + NotificationTargetType.CHAT_ROOM, + 7, + 3 + ) + ).willReturn(Optional.of(muteSetting)); + + // when + assertThatCode(() -> notificationService.sendChatNotification(3, 7, "보낸이", "메시지")) + .doesNotThrowAnyException(); + + // then + verify(notificationDeviceTokenRepository, never()).findTokensByUserId(any()); + verify(expoPushClient, never()).sendNotification(any(), any(), any(), any(), any()); + } + + @Test + @DisplayName("sendGroupChatNotification은 발신자 필터링 후 빈 수신자 목록이면 전송을 생략한다") + void sendGroupChatNotificationSkipsWhenEmptyRecipientsAfterFiltering() { + // when + assertThatCode(() -> notificationService.sendGroupChatNotification( + 10, + 1, + "KONECT", + "홍길동", + "메시지", + List.of(1) + )).doesNotThrowAnyException(); + + // then + verify(notificationDeviceTokenRepository, never()).findTokensByUserIds(any()); + verify(expoPushClient, never()).sendBatchNotifications(any()); + } + + @Test + @DisplayName("sendGroupChatNotification은 일부 사용자만 토큰이 있는 경우 처리한다") + void sendGroupChatNotificationHandlesPartialTokens() { + // given + given(chatPresenceService.findUsersInChatRoom(10, List.of(2, 3, 4))) + .willReturn(Set.of()); + given(notificationMuteSettingRepository.findMutedUserIdsByTargetTypeAndTargetIdAndUserIds( + NotificationTargetType.CHAT_ROOM, + 10, + List.of(2, 3, 4) + )).willReturn(Set.of()); + given(notificationDeviceTokenRepository.findTokensByUserIds(List.of(2, 3, 4))) + .willReturn(List.of("ExpoPushToken[token-2]", "ExpoPushToken[token-3]")); + + // when + assertThatCode(() -> notificationService.sendGroupChatNotification( + 10, + 1, + "KONECT", + "홍길동", + "메시지", + List.of(1, 2, 3, 4) + )).doesNotThrowAnyException(); + + // then + ArgumentCaptor> messagesCaptor = ArgumentCaptor.forClass(List.class); + verify(expoPushClient).sendBatchNotifications(messagesCaptor.capture()); + List messages = messagesCaptor.getValue(); + assertThat(messages).hasSize(2); + } + + @Test + @DisplayName("sendClubApplicationSubmittedNotification은 정상 동작한다") + void sendClubApplicationSubmittedNotificationWorksNormally() { + // given + User user = createUser(3, "2021136003"); + NotificationInbox inbox = NotificationInbox.of( + user, + NotificationInboxType.CLUB_APPLICATION_SUBMITTED, + "KONECT", + "홍길동님이 동아리 가입을 신청했어요.", + "mypage/manager/7/applications/1" + ); + given(notificationInboxService.save( + 3, + NotificationInboxType.CLUB_APPLICATION_SUBMITTED, + "KONECT", + "홍길동님이 동아리 가입을 신청했어요.", + "mypage/manager/7/applications/1" + )).willReturn(inbox); + given(notificationDeviceTokenRepository.findTokensByUserId(3)).willReturn(List.of(VALID_TOKEN)); + + // when + assertThatCode(() -> notificationService.sendClubApplicationSubmittedNotification( + 3, 1, 7, "KONECT", "홍길동" + )).doesNotThrowAnyException(); + + // then + verify(notificationInboxService).save( + 3, + NotificationInboxType.CLUB_APPLICATION_SUBMITTED, + "KONECT", + "홍길동님이 동아리 가입을 신청했어요.", + "mypage/manager/7/applications/1" + ); + verify(notificationInboxService).sendSse(eq(3), any(NotificationInboxResponse.class)); + verify(expoPushClient).sendNotification( + eq(3), + eq(List.of(VALID_TOKEN)), + eq("KONECT"), + eq("홍길동님이 동아리 가입을 신청했어요."), + eq(Map.of("path", "mypage/manager/7/applications/1")) + ); + } + + @Test + @DisplayName("sendClubApplicationRejectedNotification은 정상 동작한다") + void sendClubApplicationRejectedNotificationWorksNormally() { + // given + User user = createUser(3, "2021136003"); + NotificationInbox inbox = NotificationInbox.of( + user, + NotificationInboxType.CLUB_APPLICATION_REJECTED, + "KONECT", + "동아리 지원이 거절되었어요.", + "clubs/7" + ); + given(notificationInboxService.save( + 3, + NotificationInboxType.CLUB_APPLICATION_REJECTED, + "KONECT", + "동아리 지원이 거절되었어요.", + "clubs/7" + )).willReturn(inbox); + given(notificationDeviceTokenRepository.findTokensByUserId(3)).willReturn(List.of(VALID_TOKEN)); + + // when + assertThatCode(() -> notificationService.sendClubApplicationRejectedNotification(3, 7, "KONECT")) + .doesNotThrowAnyException(); + + // then + verify(notificationInboxService).save( + 3, + NotificationInboxType.CLUB_APPLICATION_REJECTED, + "KONECT", + "동아리 지원이 거절되었어요.", + "clubs/7" + ); + verify(notificationInboxService).sendSse(eq(3), any(NotificationInboxResponse.class)); + verify(expoPushClient).sendNotification( + eq(3), + eq(List.of(VALID_TOKEN)), + eq("KONECT"), + eq("동아리 지원이 거절되었어요."), + eq(Map.of("path", "clubs/7")) + ); + } + + @Test + @DisplayName("buildPreview는 null 메시지에 대해 빈 문자열을 반환한다") + void buildPreviewReturnsEmptyForNullMessage() { + // given + createUser(3, "2021136003"); + given(chatPresenceService.isUserInChatRoom(7, 3)).willReturn(false); + given( + notificationMuteSettingRepository.findByTargetTypeAndTargetIdAndUserId( + NotificationTargetType.CHAT_ROOM, + 7, + 3 + ) + ).willReturn(Optional.empty()); + given(notificationDeviceTokenRepository.findTokensByUserId(3)).willReturn(List.of(VALID_TOKEN)); + + // when + notificationService.sendChatNotification(3, 7, "보낸이", null); + + // then + verify(expoPushClient).sendNotification( + eq(3), + eq(List.of(VALID_TOKEN)), + eq("보낸이"), + eq(""), + eq(Map.of("path", "chats/7")) + ); + } + + @Test + @DisplayName("buildPreview는 빈 메시지에 대해 빈 문자열을 반환한다") + void buildPreviewReturnsEmptyForEmptyMessage() { + // given + createUser(3, "2021136003"); + given(chatPresenceService.isUserInChatRoom(7, 3)).willReturn(false); + given( + notificationMuteSettingRepository.findByTargetTypeAndTargetIdAndUserId( + NotificationTargetType.CHAT_ROOM, + 7, + 3 + ) + ).willReturn(Optional.empty()); + given(notificationDeviceTokenRepository.findTokensByUserId(3)).willReturn(List.of(VALID_TOKEN)); + + // when + notificationService.sendChatNotification(3, 7, "보낸이", ""); + + // then + verify(expoPushClient).sendNotification( + eq(3), + eq(List.of(VALID_TOKEN)), + eq("보낸이"), + eq(""), + eq(Map.of("path", "chats/7")) + ); + } + + @Test + @DisplayName("buildPreview는 최대 길이와 정확히 일치하는 메시지를 자르지 않는다") + void buildPreviewDoesNotTruncateExactLengthMessage() { + // given + String exactLengthMessage = "😀".repeat(30); + createUser(3, "2021136003"); + given(chatPresenceService.isUserInChatRoom(7, 3)).willReturn(false); + given( + notificationMuteSettingRepository.findByTargetTypeAndTargetIdAndUserId( + NotificationTargetType.CHAT_ROOM, + 7, + 3 + ) + ).willReturn(Optional.empty()); + given(notificationDeviceTokenRepository.findTokensByUserId(3)).willReturn(List.of(VALID_TOKEN)); + + // when + notificationService.sendChatNotification(3, 7, "보낸이", exactLengthMessage); + + // then + verify(expoPushClient).sendNotification( + eq(3), + eq(List.of(VALID_TOKEN)), + eq("보낸이"), + eq(exactLengthMessage), + eq(Map.of("path", "chats/7")) + ); + } + + @Test + @DisplayName("getMyToken은 토큰이 없는 사용자에 대해 예외를 발생시킨다") + void getMyTokenThrowsExceptionWhenNoTokenExists() { + // given + org.springframework.dao.EmptyResultDataAccessException exception = + new org.springframework.dao.EmptyResultDataAccessException(1); + given(notificationDeviceTokenRepository.getByUserId(999)).willThrow(exception); + + // when & then + assertThatThrownBy(() -> notificationService.getMyToken(999)) + .isInstanceOf(org.springframework.dao.EmptyResultDataAccessException.class); + } + + @Test + @DisplayName("sendChatNotification은 chatPresenceService 예외 발생 시 정상 종료한다") + void sendChatNotificationHandlesChatPresenceException() { + // given + given(chatPresenceService.isUserInChatRoom(7, 3)) + .willThrow(new RuntimeException("Presence service unavailable")); + + // when & then + assertThatCode(() -> notificationService.sendChatNotification(3, 7, "보낸이", "메시지")) + .doesNotThrowAnyException(); + + // 예외가 삼켜졌으므로 추가 작업은 수행되지 않음 + verify(notificationDeviceTokenRepository, never()).findTokensByUserId(any()); + verify(expoPushClient, never()).sendNotification(any(), any(), any(), any(), any()); + } + + @Test + @DisplayName("sendGroupChatNotification은 중복 수신자가 있어도 정상 동작한다") + void sendGroupChatNotificationHandlesDuplicateRecipients() { + // given + given(chatPresenceService.findUsersInChatRoom(10, List.of(2, 3, 2, 3))) + .willReturn(Set.of()); + given(notificationMuteSettingRepository.findMutedUserIdsByTargetTypeAndTargetIdAndUserIds( + NotificationTargetType.CHAT_ROOM, + 10, + List.of(2, 3, 2, 3) + )).willReturn(Set.of()); + given(notificationDeviceTokenRepository.findTokensByUserIds(List.of(2, 3, 2, 3))) + .willReturn(List.of("ExpoPushToken[token-2]", "ExpoPushToken[token-3]")); + + // when + notificationService.sendGroupChatNotification( + 10, + 1, + "KONECT", + "홍길동", + "메시지", + List.of(1, 2, 3, 2, 3) // 중복 ID 포함 + ); + + // then + verify(expoPushClient).sendBatchNotifications(any()); + } + + @Test + @DisplayName("sendClubApplicationSubmittedNotification은 inbox 저장과 SSE 전송을 검증한다") + void sendClubApplicationSubmittedNotificationVerifiesInboxAndSse() { + // given + User user = createUser(3, "2021136003"); + NotificationInbox inbox = NotificationInbox.of( + user, + NotificationInboxType.CLUB_APPLICATION_SUBMITTED, + "KONECT", + "홍길동님이 동아리 가입을 신청했어요.", + "mypage/manager/7/applications/1" + ); + given(notificationInboxService.save( + 3, + NotificationInboxType.CLUB_APPLICATION_SUBMITTED, + "KONECT", + "홍길동님이 동아리 가입을 신청했어요.", + "mypage/manager/7/applications/1" + )).willReturn(inbox); + given(notificationDeviceTokenRepository.findTokensByUserId(3)).willReturn(List.of(VALID_TOKEN)); + + // when + notificationService.sendClubApplicationSubmittedNotification(3, 1, 7, "KONECT", "홍길동"); + + // then + verify(notificationInboxService).save( + 3, + NotificationInboxType.CLUB_APPLICATION_SUBMITTED, + "KONECT", + "홍길동님이 동아리 가입을 신청했어요.", + "mypage/manager/7/applications/1" + ); + ArgumentCaptor responseCaptor = + ArgumentCaptor.forClass(NotificationInboxResponse.class); + verify(notificationInboxService).sendSse(eq(3), responseCaptor.capture()); + NotificationInboxResponse response = responseCaptor.getValue(); + assertThat(response.type()).isEqualTo(NotificationInboxType.CLUB_APPLICATION_SUBMITTED); + assertThat(response.title()).isEqualTo("KONECT"); + assertThat(response.body()).isEqualTo("홍길동님이 동아리 가입을 신청했어요."); + assertThat(response.path()).isEqualTo("mypage/manager/7/applications/1"); + } + + @Test + @DisplayName("sendClubApplicationApprovedNotification은 inbox 저장과 SSE 전송을 검증한다") + void sendClubApplicationApprovedNotificationVerifiesInboxAndSse() { + // given + User user = createUser(3, "2021136003"); + NotificationInbox inbox = NotificationInbox.of( + user, + NotificationInboxType.CLUB_APPLICATION_APPROVED, + "KONECT", + "동아리 지원이 승인되었어요.", + "clubs/7" + ); + given(notificationInboxService.save( + 3, + NotificationInboxType.CLUB_APPLICATION_APPROVED, + "KONECT", + "동아리 지원이 승인되었어요.", + "clubs/7" + )).willReturn(inbox); + given(notificationDeviceTokenRepository.findTokensByUserId(3)).willReturn(List.of(VALID_TOKEN)); + + // when + notificationService.sendClubApplicationApprovedNotification(3, 7, "KONECT"); + + // then + verify(notificationInboxService).save( + 3, + NotificationInboxType.CLUB_APPLICATION_APPROVED, + "KONECT", + "동아리 지원이 승인되었어요.", + "clubs/7" + ); + ArgumentCaptor responseCaptor = + ArgumentCaptor.forClass(NotificationInboxResponse.class); + verify(notificationInboxService).sendSse(eq(3), responseCaptor.capture()); + NotificationInboxResponse response = responseCaptor.getValue(); + assertThat(response.type()).isEqualTo(NotificationInboxType.CLUB_APPLICATION_APPROVED); + assertThat(response.title()).isEqualTo("KONECT"); + assertThat(response.body()).isEqualTo("동아리 지원이 승인되었어요."); + assertThat(response.path()).isEqualTo("clubs/7"); + } + + @Test + @DisplayName("sendClubApplicationRejectedNotification은 inbox 저장과 SSE 전송을 검증한다") + void sendClubApplicationRejectedNotificationVerifiesInboxAndSse() { + // given + User user = createUser(3, "2021136003"); + NotificationInbox inbox = NotificationInbox.of( + user, + NotificationInboxType.CLUB_APPLICATION_REJECTED, + "KONECT", + "동아리 지원이 거절되었어요.", + "clubs/7" + ); + given(notificationInboxService.save( + 3, + NotificationInboxType.CLUB_APPLICATION_REJECTED, + "KONECT", + "동아리 지원이 거절되었어요.", + "clubs/7" + )).willReturn(inbox); + given(notificationDeviceTokenRepository.findTokensByUserId(3)).willReturn(List.of(VALID_TOKEN)); + + // when + notificationService.sendClubApplicationRejectedNotification(3, 7, "KONECT"); + + // then + verify(notificationInboxService).save( + 3, + NotificationInboxType.CLUB_APPLICATION_REJECTED, + "KONECT", + "동아리 지원이 거절되었어요.", + "clubs/7" + ); + ArgumentCaptor responseCaptor = + ArgumentCaptor.forClass(NotificationInboxResponse.class); + verify(notificationInboxService).sendSse(eq(3), responseCaptor.capture()); + NotificationInboxResponse response = responseCaptor.getValue(); + assertThat(response.type()).isEqualTo(NotificationInboxType.CLUB_APPLICATION_REJECTED); + assertThat(response.title()).isEqualTo("KONECT"); + assertThat(response.body()).isEqualTo("동아리 지원이 거절되었어요."); + assertThat(response.path()).isEqualTo("clubs/7"); + } + + private User createUser(Integer id, String studentNumber) { + return User.builder() + .id(id) + .university(UniversityFixture.create()) + .email(studentNumber + "@koreatech.ac.kr") + .name("테스트유저" + id) + .studentNumber(studentNumber) + .isMarketingAgreement(true) + .imageUrl("https://example.com/profile-" + id + ".png") + .build(); + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/upload/service/UploadServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/upload/service/UploadServiceTest.java new file mode 100644 index 000000000..9c9801e54 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/upload/service/UploadServiceTest.java @@ -0,0 +1,858 @@ +package gg.agit.konect.unit.domain.upload.service; + +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.Mockito.*; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Locale; +import java.util.regex.Pattern; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +import gg.agit.konect.domain.upload.dto.ImageUploadResponse; +import gg.agit.konect.domain.upload.enums.UploadTarget; +import gg.agit.konect.domain.upload.service.UploadService; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.infrastructure.storage.cdn.StorageCdnProperties; +import gg.agit.konect.infrastructure.storage.s3.S3StorageProperties; +import gg.agit.konect.support.ServiceTestSupport; +import software.amazon.awssdk.core.exception.SdkClientException; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; + +@Execution(ExecutionMode.SAME_THREAD) +class UploadServiceTest extends ServiceTestSupport { + + private static final Pattern CLUB_KEY_PATTERN = Pattern.compile( + "(?:[\\w-]+/)*club/\\d{4}-\\d{2}-\\d{2}-[0-9a-f\\-]{36}\\.png" + ); + + @Mock + private S3Client s3Client; + + private UploadService uploadService; + + @BeforeEach + void setUp() { + uploadService = new UploadService( + s3Client, + new S3StorageProperties("konect-bucket", "ap-northeast-2", "konect", 5_000L), + new StorageCdnProperties("https://cdn.konect.test/") + ); + } + + @Test + @DisplayName("uploadImage는 유효한 PNG 파일을 업로드하고 key와 CDN URL을 반환한다") + void uploadImageUploadsPngAndReturnsKeyAndCdnUrl() { + // given + MockMultipartFile file = new MockMultipartFile( + "file", + "logo.png", + "image/png", + "png-data".getBytes(StandardCharsets.UTF_8) + ); + + // when + ImageUploadResponse response = uploadService.uploadImage(file, UploadTarget.CLUB); + + // then + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class); + verify(s3Client).putObject(requestCaptor.capture(), any(RequestBody.class)); + + PutObjectRequest request = requestCaptor.getValue(); + assertThat(request.bucket()).isEqualTo("konect-bucket"); + assertThat(request.contentType()).isEqualTo("image/png"); + assertThat(request.key()).matches(CLUB_KEY_PATTERN); + assertThat(response.key()).isEqualTo(request.key()); + assertThat(response.fileUrl()).isEqualTo("https://cdn.konect.test/" + request.key()); + } + + @Test + @DisplayName("uploadImage는 blank prefix여도 target 경로만 포함한 key를 만든다") + void uploadImageBuildsKeyWithoutPrefixWhenPrefixBlank() { + // given + UploadService service = new UploadService( + s3Client, + new S3StorageProperties("konect-bucket", "ap-northeast-2", " ", 5_000L), + new StorageCdnProperties("https://cdn.konect.test") + ); + MockMultipartFile file = new MockMultipartFile( + "file", + "bank.png", + "image/png", + "png-data".getBytes(StandardCharsets.UTF_8) + ); + + // when + ImageUploadResponse response = service.uploadImage(file, UploadTarget.BANK); + + // then + assertThat(response.key()).matches("bank/\\d{4}-\\d{2}-\\d{2}-[0-9a-f\\-]{36}\\.png"); + } + + @Test + @DisplayName("uploadImage는 leading slash prefix를 제거하고 trailing slash를 보정한다") + void uploadImageNormalizesPrefixWithLeadingSlash() { + // given + UploadService service = new UploadService( + s3Client, + new S3StorageProperties("konect-bucket", "ap-northeast-2", "/assets", 5_000L), + new StorageCdnProperties("https://cdn.konect.test") + ); + MockMultipartFile file = new MockMultipartFile( + "file", + "user.png", + "image/png", + "png-data".getBytes(StandardCharsets.UTF_8) + ); + + // when + ImageUploadResponse response = service.uploadImage(file, UploadTarget.USER); + + // then + assertThat(response.key()).matches("assets/user/\\d{4}-\\d{2}-\\d{2}-[0-9a-f\\-]{36}\\.png"); + assertThat(response.key()).doesNotStartWith("/"); + } + + @Test + @DisplayName("uploadImage는 null 파일을 거부한다") + void uploadImageRejectsNullFile() { + assertCustomException( + () -> uploadService.uploadImage(null, UploadTarget.CLUB), + ApiResponseCode.INVALID_REQUEST_BODY + ); + verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @Test + @DisplayName("uploadImage는 empty 파일을 거부한다") + void uploadImageRejectsEmptyFile() { + // given + MockMultipartFile file = new MockMultipartFile("file", "empty.png", "image/png", new byte[0]); + + // when & then + assertCustomException( + () -> uploadService.uploadImage(file, UploadTarget.CLUB), + ApiResponseCode.INVALID_REQUEST_BODY + ); + verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @Test + @DisplayName("uploadImage는 blank content-type을 거부한다") + void uploadImageRejectsBlankContentType() { + // given + MultipartFile file = mockFile("image.png", " ", 10L); + + // when & then + assertCustomException( + () -> uploadService.uploadImage(file, UploadTarget.CLUB), + ApiResponseCode.INVALID_FILE_CONTENT_TYPE + ); + } + + @Test + @DisplayName("uploadImage는 허용되지 않은 content-type을 거부한다") + void uploadImageRejectsUnsupportedContentType() { + // given + MultipartFile file = mockFile("document.pdf", "application/pdf", 10L); + + // when & then + assertCustomException( + () -> uploadService.uploadImage(file, UploadTarget.CLUB), + ApiResponseCode.INVALID_FILE_CONTENT_TYPE + ); + } + + @Test + @DisplayName("uploadImage는 maxUploadBytes를 초과하면 PAYLOAD_TOO_LARGE로 실패한다") + void uploadImageRejectsOversizedFile() { + // given + MultipartFile file = mockFile("large.png", "image/png", 5_001L); + + // when & then + assertCustomException( + () -> uploadService.uploadImage(file, UploadTarget.CLUB), + ApiResponseCode.PAYLOAD_TOO_LARGE + ); + verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @Test + @DisplayName("uploadImage는 S3Exception을 FAILED_UPLOAD_FILE로 변환한다") + void uploadImageConvertsS3Exception() { + // given + MockMultipartFile file = new MockMultipartFile( + "file", + "logo.png", + "image/png", + "png-data".getBytes(StandardCharsets.UTF_8) + ); + when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class))) + .thenThrow(S3Exception.builder().message("s3 failed").build()); + + // when & then + assertCustomException( + () -> uploadService.uploadImage(file, UploadTarget.CLUB), + ApiResponseCode.FAILED_UPLOAD_FILE + ); + } + + @Test + @DisplayName("uploadImage는 SdkClientException을 FAILED_UPLOAD_FILE로 변환한다") + void uploadImageConvertsSdkClientException() { + // given + MockMultipartFile file = new MockMultipartFile( + "file", + "logo.png", + "image/png", + "png-data".getBytes(StandardCharsets.UTF_8) + ); + when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class))) + .thenThrow(SdkClientException.create("client failed")); + + // when & then + assertCustomException( + () -> uploadService.uploadImage(file, UploadTarget.CLUB), + ApiResponseCode.FAILED_UPLOAD_FILE + ); + } + + @Test + @DisplayName("uploadImage는 InputStream IOException을 FAILED_UPLOAD_FILE로 변환한다") + void uploadImageConvertsIOException() throws IOException { + // given + MultipartFile file = mock(MultipartFile.class); + when(file.isEmpty()).thenReturn(false); + when(file.getSize()).thenReturn(10L); + when(file.getContentType()).thenReturn("image/png"); + when(file.getInputStream()).thenThrow(new IOException("stream failed")); + + // when & then + assertCustomException( + () -> uploadService.uploadImage(file, UploadTarget.CLUB), + ApiResponseCode.FAILED_UPLOAD_FILE + ); + } + + @Test + @DisplayName("uploadImage는 target이 null이면 target 경로 없이 key를 생성한다") + void uploadImageBuildsKeyWithoutTargetWhenTargetNull() { + // given + MockMultipartFile file = new MockMultipartFile( + "file", + "logo.png", + "image/png", + "png-data".getBytes(StandardCharsets.UTF_8) + ); + + // when + ImageUploadResponse response = uploadService.uploadImage(file, null); + + // then + assertThat(response.key()).matches( + "konect/\\d{4}-\\d{2}-\\d{2}-[0-9a-f\\-]{36}\\.png" + ); + assertThat(response.key()).doesNotContain("//"); + } + + @Test + @DisplayName("uploadImage는 image/jpeg를 .jpg 확장자로 변환한다") + void uploadImageConvertsJpegToJpgExtension() { + // given + MockMultipartFile file = new MockMultipartFile( + "file", + "photo.jpeg", + "image/jpeg", + "jpeg-data".getBytes(StandardCharsets.UTF_8) + ); + + // when + ImageUploadResponse response = uploadService.uploadImage(file, UploadTarget.CLUB); + + // then + assertThat(response.key()).endsWith(".jpg"); + assertThat(response.fileUrl()).endsWith(".jpg"); + } + + @Test + @DisplayName("uploadImage는 image/jpg를 .jpg 확장자로 변환한다") + void uploadImageConvertsJpgToJpgExtension() { + // given + MockMultipartFile file = new MockMultipartFile( + "file", + "photo.jpg", + "image/jpg", + "jpg-data".getBytes(StandardCharsets.UTF_8) + ); + + // when + ImageUploadResponse response = uploadService.uploadImage(file, UploadTarget.CLUB); + + // then + assertThat(response.key()).endsWith(".jpg"); + } + + @Test + @DisplayName("uploadImage는 image/webp를 .webp 확장자로 변환한다") + void uploadImageConvertsWebpToWebpExtension() { + // given + MockMultipartFile file = new MockMultipartFile( + "file", + "photo.webp", + "image/webp", + "webp-data".getBytes(StandardCharsets.UTF_8) + ); + + // when + ImageUploadResponse response = uploadService.uploadImage(file, UploadTarget.CLUB); + + // then + assertThat(response.key()).endsWith(".webp"); + } + + @Test + @DisplayName("uploadImage는 파일 크기가 maxUploadBytes와 정확히 일치하면 업로드에 성공한다") + void uploadImageAcceptsFileAtExactMaxSize() { + // given + MockMultipartFile file = new MockMultipartFile( + "file", + "logo.png", + "image/png", + new byte[5_000] + ); + + // when + ImageUploadResponse response = uploadService.uploadImage(file, UploadTarget.CLUB); + + // then + assertThat(response.key()).isNotBlank(); + assertThat(response.fileUrl()).isNotBlank(); + verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @Test + @DisplayName("uploadImage는 content-type이 null이면 INVALID_FILE_CONTENT_TYPE으로 실패한다") + void uploadImageRejectsNullContentType() { + // given + MultipartFile file = mock(MultipartFile.class); + when(file.isEmpty()).thenReturn(false); + when(file.getSize()).thenReturn(10L); + when(file.getContentType()).thenReturn(null); + + // when & then + assertCustomException( + () -> uploadService.uploadImage(file, UploadTarget.CLUB), + ApiResponseCode.INVALID_FILE_CONTENT_TYPE + ); + verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @Test + @DisplayName("uploadImage는 maxUploadBytes가 null이면 용량 검증을 생략한다") + void uploadImageSkipsSizeCheckWhenMaxUploadBytesNull() { + // given + UploadService service = new UploadService( + s3Client, + new S3StorageProperties("konect-bucket", "ap-northeast-2", "konect", null), + new StorageCdnProperties("https://cdn.konect.test/") + ); + MockMultipartFile file = new MockMultipartFile( + "file", + "big.png", + "image/png", + new byte[1_000_000] + ); + + // when + ImageUploadResponse response = service.uploadImage(file, UploadTarget.CLUB); + + // then + assertThat(response.key()).isNotBlank(); + verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @Test + @DisplayName("uploadImage는 maxUploadBytes가 0이면 용량 검증을 생략한다") + void uploadImageSkipsSizeCheckWhenMaxUploadBytesZero() { + // given + UploadService service = new UploadService( + s3Client, + new S3StorageProperties("konect-bucket", "ap-northeast-2", "konect", 0L), + new StorageCdnProperties("https://cdn.konect.test/") + ); + MockMultipartFile file = new MockMultipartFile( + "file", + "big.png", + "image/png", + new byte[1_000_000] + ); + + // when + ImageUploadResponse response = service.uploadImage(file, UploadTarget.CLUB); + + // then + assertThat(response.key()).isNotBlank(); + verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @Test + @DisplayName("uploadImage는 CDN URL에 trailing slash가 없어도 정상 동작한다") + void uploadImageWorksWithCdnUrlWithoutTrailingSlash() { + // given + UploadService service = new UploadService( + s3Client, + new S3StorageProperties("konect-bucket", "ap-northeast-2", "konect", 5_000L), + new StorageCdnProperties("https://cdn.konect.test") + ); + MockMultipartFile file = new MockMultipartFile( + "file", + "logo.png", + "image/png", + "data".getBytes(StandardCharsets.UTF_8) + ); + + // when + ImageUploadResponse response = service.uploadImage(file, UploadTarget.CLUB); + + // then + assertThat(response.fileUrl()).startsWith("https://cdn.konect.test/"); + assertThat(response.fileUrl()).doesNotContain("//cdn.konect.test//"); + } + + @Test + @DisplayName("uploadImage는 prefix에 이미 trailing slash가 있으면 중복 slash 없이 key를 생성한다") + void uploadImageHandlesPrefixWithExistingTrailingSlash() { + // given + UploadService service = new UploadService( + s3Client, + new S3StorageProperties("konect-bucket", "ap-northeast-2", "konect/", 5_000L), + new StorageCdnProperties("https://cdn.konect.test/") + ); + MockMultipartFile file = new MockMultipartFile( + "file", + "logo.png", + "image/png", + "data".getBytes(StandardCharsets.UTF_8) + ); + + // when + ImageUploadResponse response = service.uploadImage(file, UploadTarget.CLUB); + + // then + assertThat(response.key()).matches("konect/club/\\d{4}-\\d{2}-\\d{2}-[0-9a-f\\-]{36}\\.png"); + assertThat(response.key()).doesNotContain("konect//"); + } + + @Test + @DisplayName("uploadImage는 prefix에 leading과 trailing slash가 모두 있어도 정상 동작한다") + void uploadImageHandlesPrefixWithBothLeadingAndTrailingSlash() { + // given + UploadService service = new UploadService( + s3Client, + new S3StorageProperties("konect-bucket", "ap-northeast-2", "/assets/", 5_000L), + new StorageCdnProperties("https://cdn.konect.test/") + ); + MockMultipartFile file = new MockMultipartFile( + "file", + "logo.png", + "image/png", + "data".getBytes(StandardCharsets.UTF_8) + ); + + // when + ImageUploadResponse response = service.uploadImage(file, UploadTarget.COUNCIL); + + // then + assertThat(response.key()).matches("assets/council/\\d{4}-\\d{2}-\\d{2}-[0-9a-f\\-]{36}\\.png"); + assertThat(response.key()).doesNotStartWith("/"); + assertThat(response.key()).doesNotContain("//"); + } + + @Test + @DisplayName("uploadImage는 S3Exception의 awsErrorDetails가 null이어도 FAILED_UPLOAD_FILE로 변환한다") + void uploadImageConvertsS3ExceptionWithNullErrorDetails() { + // given + MockMultipartFile file = new MockMultipartFile( + "file", + "logo.png", + "image/png", + "png-data".getBytes(StandardCharsets.UTF_8) + ); + when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class))) + .thenThrow(S3Exception.builder().message("s3 failed").statusCode(500).build()); + + // when & then + assertCustomException( + () -> uploadService.uploadImage(file, UploadTarget.CLUB), + ApiResponseCode.FAILED_UPLOAD_FILE + ); + } + + @Test + @DisplayName("uploadImage는 CDN URL이 비어 있으면 ILLEGAL_STATE로 실패한다") + void uploadImageFailsWhenCdnBaseUrlMissing() { + // given + UploadService service = new UploadService( + s3Client, + new S3StorageProperties("konect-bucket", "ap-northeast-2", "konect", 5_000L), + new StorageCdnProperties(" ") + ); + MockMultipartFile file = new MockMultipartFile( + "file", + "logo.png", + "image/png", + "png-data".getBytes(StandardCharsets.UTF_8) + ); + + // when & then + assertCustomException(() -> service.uploadImage(file, UploadTarget.CLUB), ApiResponseCode.ILLEGAL_STATE); + } + + @Test + @DisplayName("uploadImage는 CDN URL이 null이면 ILLEGAL_STATE로 실패한다") + void uploadImageFailsWhenCdnBaseUrlNull() { + // given + UploadService service = new UploadService( + s3Client, + new S3StorageProperties("konect-bucket", "ap-northeast-2", "konect", 5_000L), + new StorageCdnProperties(null) + ); + MockMultipartFile file = new MockMultipartFile( + "file", + "logo.png", + "image/png", + "png-data".getBytes(StandardCharsets.UTF_8) + ); + + // when & then + assertCustomException(() -> service.uploadImage(file, UploadTarget.CLUB), ApiResponseCode.ILLEGAL_STATE); + } + + @Test + @DisplayName("uploadImage는 CDN URL이 슬래시만 있으면 ILLEGAL_STATE로 실패한다") + void uploadImageFailsWhenCdnBaseUrlContainsOnlySlashes() { + // given + UploadService service = new UploadService( + s3Client, + new S3StorageProperties("konect-bucket", "ap-northeast-2", "konect", 5_000L), + new StorageCdnProperties("////") + ); + MockMultipartFile file = new MockMultipartFile( + "file", + "logo.png", + "image/png", + "png-data".getBytes(StandardCharsets.UTF_8) + ); + + // when & then + assertCustomException(() -> service.uploadImage(file, UploadTarget.CLUB), ApiResponseCode.ILLEGAL_STATE); + } + + @Test + @DisplayName("uploadImage는 bucket 설정이 비어 있으면 ILLEGAL_STATE로 실패한다") + void uploadImageFailsWhenBucketMissing() { + // given + UploadService service = new UploadService( + s3Client, + new S3StorageProperties(" ", "ap-northeast-2", "konect", 5_000L), + new StorageCdnProperties("https://cdn.konect.test") + ); + MockMultipartFile file = new MockMultipartFile( + "file", + "logo.png", + "image/png", + "png-data".getBytes(StandardCharsets.UTF_8) + ); + + // when & then + assertCustomException(() -> service.uploadImage(file, UploadTarget.CLUB), ApiResponseCode.ILLEGAL_STATE); + } + + @Test + @DisplayName("uploadImage는 region 설정이 비어 있으면 ILLEGAL_STATE로 실패한다") + void uploadImageFailsWhenRegionMissing() { + // given + UploadService service = new UploadService( + s3Client, + new S3StorageProperties("konect-bucket", " ", "konect", 5_000L), + new StorageCdnProperties("https://cdn.konect.test") + ); + MockMultipartFile file = new MockMultipartFile( + "file", + "logo.png", + "image/png", + "png-data".getBytes(StandardCharsets.UTF_8) + ); + + // when & then + assertCustomException(() -> service.uploadImage(file, UploadTarget.CLUB), ApiResponseCode.ILLEGAL_STATE); + } + + @Test + @DisplayName("uploadImage는 bucket이 null이면 ILLEGAL_STATE로 실패한다") + void uploadImageFailsWhenBucketNull() { + // given + UploadService service = new UploadService( + s3Client, + new S3StorageProperties(null, "ap-northeast-2", "konect", 5_000L), + new StorageCdnProperties("https://cdn.konect.test") + ); + MockMultipartFile file = new MockMultipartFile( + "file", "logo.png", "image/png", + "data".getBytes(StandardCharsets.UTF_8) + ); + + // when & then + assertCustomException( + () -> service.uploadImage(file, UploadTarget.CLUB), + ApiResponseCode.ILLEGAL_STATE + ); + } + + @Test + @DisplayName("uploadImage는 region이 null이면 ILLEGAL_STATE로 실패한다") + void uploadImageFailsWhenRegionNull() { + // given + UploadService service = new UploadService( + s3Client, + new S3StorageProperties("konect-bucket", null, "konect", 5_000L), + new StorageCdnProperties("https://cdn.konect.test") + ); + MockMultipartFile file = new MockMultipartFile( + "file", "logo.png", "image/png", + "data".getBytes(StandardCharsets.UTF_8) + ); + + // when & then + assertCustomException( + () -> service.uploadImage(file, UploadTarget.CLUB), + ApiResponseCode.ILLEGAL_STATE + ); + } + + @Test + @DisplayName("uploadImage는 maxUploadBytes가 음수면 용량 검증을 생략한다") + void uploadImageSkipsSizeCheckWhenMaxUploadBytesNegative() { + // given + UploadService service = new UploadService( + s3Client, + new S3StorageProperties("konect-bucket", "ap-northeast-2", "konect", -1L), + new StorageCdnProperties("https://cdn.konect.test/") + ); + MockMultipartFile file = new MockMultipartFile( + "file", "big.png", "image/png", + new byte[1_000_000] + ); + + // when + ImageUploadResponse response = service.uploadImage(file, UploadTarget.CLUB); + + // then + assertThat(response.key()).isNotBlank(); + verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @Test + @DisplayName("uploadImage는 CDN baseUrl에 double trailing slash가 있어도 fileUrl에 double slash가 없다") + void uploadImageRemovesAllTrailingSlashesFromCdnBaseUrl() { + UploadService service = new UploadService( + s3Client, + new S3StorageProperties("konect-bucket", "ap-northeast-2", "konect", 5_000L), + new StorageCdnProperties("https://cdn.konect.test//") + ); + MockMultipartFile file = new MockMultipartFile( + "file", "logo.png", "image/png", + "data".getBytes(StandardCharsets.UTF_8) + ); + + // when + ImageUploadResponse response = service.uploadImage(file, UploadTarget.CLUB); + + // then + assertThat(response.fileUrl()).startsWith("https://cdn.konect.test/"); + assertThat(response.fileUrl()).doesNotContain("cdn.konect.test//"); + } + + @Test + @DisplayName("uploadImage는 대문자 content-type 'IMAGE/PNG'을 정규화하여 허용한다") + void uploadImageAcceptsUppercaseContentType() { + MultipartFile file = mockFile("image.png", "IMAGE/PNG", 10L); + + // when + ImageUploadResponse response = uploadService.uploadImage(file, UploadTarget.CLUB); + + // then + assertThat(response.key()).endsWith(".png"); + verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @Test + @DisplayName("uploadImage는 혼합 대소문자 content-type 'image/PNG'을 정규화하여 허용한다") + void uploadImageAcceptsMixedCaseContentType() { + MultipartFile file = mockFile("image.png", "image/PNG", 10L); + + // when + ImageUploadResponse response = uploadService.uploadImage(file, UploadTarget.CLUB); + + // then + assertThat(response.key()).endsWith(".png"); + } + + @Test + @DisplayName("uploadImage는 content-type에 leading whitespace가 있어도 정규화하여 허용한다") + void uploadImageAcceptsContentTypeWithLeadingWhitespace() { + MultipartFile file = mockFile("image.png", " image/png", 10L); + + // when + ImageUploadResponse response = uploadService.uploadImage(file, UploadTarget.CLUB); + + // then + assertThat(response.key()).endsWith(".png"); + } + + @Test + @DisplayName("uploadImage는 content-type에 trailing whitespace가 있어도 정규화하여 허용한다") + void uploadImageAcceptsContentTypeWithTrailingWhitespace() { + MultipartFile file = mockFile("image.png", "image/png ", 10L); + + // when + ImageUploadResponse response = uploadService.uploadImage(file, UploadTarget.CLUB); + + // then + assertThat(response.key()).endsWith(".png"); + } + + @Test + @DisplayName("uploadImage는 터키어 기본 로케일에서도 대문자 content-type을 정규화하여 허용한다") + void uploadImageAcceptsUppercaseContentTypeInTurkishLocale() { + Locale defaultLocale = Locale.getDefault(); + Locale.setDefault(Locale.forLanguageTag("tr")); + + try { + MultipartFile file = mockFile("image.png", "IMAGE/PNG", 10L); + + ImageUploadResponse response = uploadService.uploadImage(file, UploadTarget.CLUB); + + assertThat(response.key()).endsWith(".png"); + } finally { + Locale.setDefault(defaultLocale); + } + } + + @Test + @DisplayName("uploadImage는 fileUrl을 baseUrl + '/' + key 형식으로 정확히 생성한다") + void uploadImageConstructsFileUrlAsBaseUrlSlashKey() { + // given + UploadService service = new UploadService( + s3Client, + new S3StorageProperties("konect-bucket", "ap-northeast-2", "konect", 5_000L), + new StorageCdnProperties("https://cdn.konect.test") + ); + MockMultipartFile file = new MockMultipartFile( + "file", "logo.png", "image/png", + "data".getBytes(StandardCharsets.UTF_8) + ); + + // when + ImageUploadResponse response = service.uploadImage(file, UploadTarget.CLUB); + + // then + assertThat(response.fileUrl()) + .isEqualTo("https://cdn.konect.test/" + response.key()); + assertThat(response.fileUrl()).doesNotMatch(".*://.*//.*"); + } + + @Test + @DisplayName("uploadImage는 PutObjectRequest에 정규화된 content-type을 전달한다") + void uploadImagePassesNormalizedContentTypeToPutObjectRequest() { + // given — content-type이 정규화(trim + lowercase)되어 S3에 전달됨 + MockMultipartFile file = new MockMultipartFile( + "file", "photo.jpg", "IMAGE/JPEG", + "jpeg-data".getBytes(StandardCharsets.UTF_8) + ); + + // when + uploadService.uploadImage(file, UploadTarget.CLUB); + + // then + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class); + verify(s3Client).putObject(requestCaptor.capture(), any(RequestBody.class)); + assertThat(requestCaptor.getValue().contentType()).isEqualTo("image/jpeg"); + } + + @Test + @DisplayName("uploadImage는 RequestBody에 file.getSize() 값을 그대로 전달한다") + void uploadImagePassesExactFileSizeToRequestBody() { + // given + byte[] content = new byte[1234]; + MockMultipartFile file = new MockMultipartFile( + "file", "logo.png", "image/png", content + ); + + // when + uploadService.uploadImage(file, UploadTarget.CLUB); + + // then + ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(RequestBody.class); + verify(s3Client).putObject(any(PutObjectRequest.class), bodyCaptor.capture()); + assertThat(bodyCaptor.getValue().contentLength()).isEqualTo(1234); + } + + @Test + @DisplayName("uploadImage는 PutObjectRequest의 bucket이 설정값과 일치한다") + void uploadImageUsesConfiguredBucketInPutObjectRequest() { + // given + UploadService service = new UploadService( + s3Client, + new S3StorageProperties("my-custom-bucket", "ap-northeast-2", "konect", 5_000L), + new StorageCdnProperties("https://cdn.konect.test") + ); + MockMultipartFile file = new MockMultipartFile( + "file", "logo.png", "image/png", + "data".getBytes(StandardCharsets.UTF_8) + ); + + // when + service.uploadImage(file, UploadTarget.CLUB); + + // then + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class); + verify(s3Client).putObject(requestCaptor.capture(), any(RequestBody.class)); + assertThat(requestCaptor.getValue().bucket()).isEqualTo("my-custom-bucket"); + } + + private MultipartFile mockFile(String filename, String contentType, long size) { + return new MockMultipartFile( + "file", + filename, + contentType, + new byte[Math.toIntExact(size)] + ); + } + + private void assertCustomException(ThrowingCallable callable, ApiResponseCode expectedCode) { + assertThatThrownBy(callable::call) + .isInstanceOf(CustomException.class) + .satisfies(exception -> assertThat(((CustomException)exception).getErrorCode()).isEqualTo(expectedCode)); + } + + @FunctionalInterface + private interface ThrowingCallable { + void call() throws Exception; + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/user/service/RefreshTokenServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/user/service/RefreshTokenServiceTest.java new file mode 100644 index 000000000..a7ec5c8e0 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/user/service/RefreshTokenServiceTest.java @@ -0,0 +1,350 @@ +package gg.agit.konect.unit.domain.user.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Instant; +import java.util.Date; +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.MACSigner; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; + +import gg.agit.konect.domain.user.service.RefreshTokenService; +import gg.agit.konect.global.auth.jwt.JwtProperties; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.support.ServiceTestSupport; + +class RefreshTokenServiceTest extends ServiceTestSupport { + + private static final String VALID_SECRET = "0123456789abcdef0123456789abcdef"; + private static final String VALID_ISSUER = "konect"; + + private final RefreshTokenService refreshTokenService = new RefreshTokenService( + new JwtProperties(VALID_SECRET, VALID_ISSUER) + ); + + @Test + @DisplayName("issue와 extractUserId는 정상 리프레시 토큰을 왕복 처리한다") + void issueAndExtractUserIdRoundTrip() { + // when + String token = refreshTokenService.issue(123); + + // then + assertThat(refreshTokenService.extractUserId(token)).isEqualTo(123); + } + + @Test + @DisplayName("rotate는 같은 사용자 ID를 유지하면서 새 토큰을 발급한다") + void rotateIssuesNewTokenForSameUser() { + // given + String originalToken = refreshTokenService.issue(7); + + // when + RefreshTokenService.Rotated rotated = refreshTokenService.rotate(originalToken); + + // then + assertThat(rotated.userId()).isEqualTo(7); + assertThat(rotated.refreshToken()).isNotBlank(); + assertThat(rotated.refreshToken()).isNotEqualTo(originalToken); + assertThat(refreshTokenService.extractUserId(rotated.refreshToken())).isEqualTo(7); + } + + @Test + @DisplayName("extractUserId는 빈 토큰을 거부한다") + void extractUserIdRejectsBlankToken() { + assertInvalidRefreshToken(() -> refreshTokenService.extractUserId(" ")); + } + + @Test + @DisplayName("extractUserId는 서명이 다른 토큰을 거부한다") + void extractUserIdRejectsTokenSignedWithDifferentSecret() { + // given + RefreshTokenService otherService = new RefreshTokenService( + new JwtProperties("fedcba9876543210fedcba9876543210", VALID_ISSUER) + ); + String token = otherService.issue(9); + + // when & then + assertInvalidRefreshToken(() -> refreshTokenService.extractUserId(token)); + } + + @Test + @DisplayName("extractUserId는 만료된 토큰을 거부한다") + void extractUserIdRejectsExpiredToken() throws JOSEException { + // given + String token = createToken(11, VALID_ISSUER, "refresh", Instant.now().minusSeconds(5), VALID_SECRET); + + // when & then + assertInvalidRefreshToken(() -> refreshTokenService.extractUserId(token)); + } + + @Test + @DisplayName("extractUserId는 refresh 타입이 아니면 거부한다") + void extractUserIdRejectsNonRefreshTokenType() throws JOSEException { + // given + String token = createToken(11, VALID_ISSUER, "access", Instant.now().plusSeconds(60), VALID_SECRET); + + // when & then + assertInvalidRefreshToken(() -> refreshTokenService.extractUserId(token)); + } + + @Test + @DisplayName("issue는 userId가 없으면 IllegalArgumentException을 던진다") + void issueRejectsNullUserId() { + assertThatThrownBy(() -> refreshTokenService.issue(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("userId is required"); + } + + @Test + @DisplayName("issue는 issuer 설정이 비어 있으면 실패한다") + void issueFailsWhenIssuerMissing() { + RefreshTokenService service = new RefreshTokenService(new JwtProperties(VALID_SECRET, " ")); + + assertThatThrownBy(() -> service.issue(1)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("app.jwt.issuer is required"); + } + + @Test + @DisplayName("issue는 secret 길이가 32바이트보다 짧으면 실패한다") + void issueFailsWhenSecretTooShort() { + RefreshTokenService service = new RefreshTokenService(new JwtProperties("short-secret", VALID_ISSUER)); + + assertThatThrownBy(() -> service.issue(1)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("app.jwt.secret must be at least 32 bytes"); + } + + @Test + @DisplayName("issue는 userId가 0이어도 토큰을 발급한다") + void issueAcceptsZeroUserId() { + // when + String token = refreshTokenService.issue(0); + + // then + assertThat(refreshTokenService.extractUserId(token)).isEqualTo(0); + } + + @Test + @DisplayName("extractUserId는 빈 문자열을 거부한다") + void extractUserIdRejectsEmptyString() { + assertInvalidRefreshToken(() -> refreshTokenService.extractUserId("")); + } + + @Test + @DisplayName("extractUserId는 null을 거부한다") + void extractUserIdRejectsNull() { + assertInvalidRefreshToken(() -> refreshTokenService.extractUserId(null)); + } + + @Test + @DisplayName("extractUserId는 탭/개행 문자열을 거부한다") + void extractUserIdRejectsTabNewlineString() { + assertInvalidRefreshToken(() -> refreshTokenService.extractUserId("\t\n")); + } + + @Test + @DisplayName("extractUserId는 잘린/손상된 토큰을 거부한다") + void extractUserIdRejectsMalformedToken() { + // given + String validToken = refreshTokenService.issue(123); + String truncatedToken = validToken.substring(0, validToken.length() / 2); + + // when & then + assertInvalidRefreshToken(() -> refreshTokenService.extractUserId(truncatedToken)); + } + + @Test + @DisplayName("extractUserId는 token_type 클레임이 누락된 토큰을 거부한다") + void extractUserIdRejectsMissingTokenTypeClaim() throws JOSEException { + // given + String token = createTokenWithoutClaim(Integer.valueOf(11), VALID_ISSUER, Instant.now().plusSeconds(60), + VALID_SECRET, "token_type"); + + // when & then + assertInvalidRefreshToken(() -> refreshTokenService.extractUserId(token)); + } + + @Test + @DisplayName("extractUserId는 token_type 클레임이 비문자열 타입이면 거부한다") + void extractUserIdRejectsNonStringTokenTypeClaim() throws JOSEException { + // given + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .issuer(VALID_ISSUER) + .issueTime(Date.from(Instant.now().minusSeconds(10))) + .expirationTime(Date.from(Instant.now().plusSeconds(60))) + .jwtID(UUID.randomUUID().toString()) + .claim("id", 11) + .claim("token_type", 123) // 숫자 타입 + .build(); + + SignedJWT jwt = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), claims); + jwt.sign(new MACSigner(VALID_SECRET)); + String token = jwt.serialize(); + + // when & then + assertInvalidRefreshToken(() -> refreshTokenService.extractUserId(token)); + } + + @Test + @DisplayName("extractUserId는 id 클레임이 누락된 토큰을 거부한다") + void extractUserIdRejectsMissingIdClaim() throws JOSEException { + // given + String token = createTokenWithoutClaim(Integer.valueOf(11), VALID_ISSUER, Instant.now().plusSeconds(60), + VALID_SECRET, "id"); + + // when & then + assertInvalidRefreshToken(() -> refreshTokenService.extractUserId(token)); + } + + @Test + @DisplayName("extractUserId는 id 클레임이 비숫자 타입이면 거부한다") + void extractUserIdRejectsNonNumericIdClaim() throws JOSEException { + // given + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .issuer(VALID_ISSUER) + .issueTime(Date.from(Instant.now().minusSeconds(10))) + .expirationTime(Date.from(Instant.now().plusSeconds(60))) + .jwtID(UUID.randomUUID().toString()) + .claim("id", "not-a-number") // 문자열 타입 + .claim("token_type", "refresh") + .build(); + + SignedJWT jwt = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), claims); + jwt.sign(new MACSigner(VALID_SECRET)); + String token = jwt.serialize(); + + // when & then + assertInvalidRefreshToken(() -> refreshTokenService.extractUserId(token)); + } + + @Test + @DisplayName("extractUserId는 issuer 클레임이 null이면 거부한다") + void extractUserIdRejectsNullIssuerClaim() throws JOSEException { + // given + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .issuer((String)null) // null issuer + .issueTime(Date.from(Instant.now().minusSeconds(10))) + .expirationTime(Date.from(Instant.now().plusSeconds(60))) + .jwtID(UUID.randomUUID().toString()) + .claim("id", 11) + .claim("token_type", "refresh") + .build(); + + SignedJWT jwt = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), claims); + jwt.sign(new MACSigner(VALID_SECRET)); + String token = jwt.serialize(); + + // when & then + assertInvalidRefreshToken(() -> refreshTokenService.extractUserId(token)); + } + + @Test + @DisplayName("extractUserId는 만료 시간이 현재 시간과 정확히 일치하는 경계를 테스트한다") + void extractUserIdHandlesExpirationTimeBoundary() throws JOSEException, InterruptedException { + // given - 현재 시간에서 1초 후에 만료되는 토큰 생성 + Instant expirationTime = Instant.now().plusSeconds(1); + String token = createToken(11, VALID_ISSUER, "refresh", expirationTime, VALID_SECRET); + + // when - 즉시 검증하면 통과해야 함 + Integer userId = refreshTokenService.extractUserId(token); + assertThat(userId).isEqualTo(11); + + // when - 2초 대기 후 만료된 토큰을 검증하면 실패해야 함 + Thread.sleep(2000); + assertInvalidRefreshToken(() -> refreshTokenService.extractUserId(token)); + } + + @Test + @DisplayName("extractUserId는 issuer가 빈 문자열인 토큰을 거부한다") + void extractUserIdRejectsEmptyIssuerClaim() throws JOSEException { + // given + String token = createToken(11, "", "refresh", Instant.now().plusSeconds(60), VALID_SECRET); + + // when & then + assertInvalidRefreshToken(() -> refreshTokenService.extractUserId(token)); + } + + @Test + @DisplayName("rotate는 만료된 토큰으로 rotate 시도 시 INVALID_REFRESH_TOKEN 예외 발생") + void rotateRejectsExpiredToken() throws JOSEException { + // given + String expiredToken = createToken(11, VALID_ISSUER, "refresh", Instant.now().minusSeconds(5), VALID_SECRET); + + // when & then + assertInvalidRefreshToken(() -> refreshTokenService.rotate(expiredToken)); + } + + @Test + @DisplayName("issue와 extractUserId는 토큰 claim 검증 왕복 테스트") + void issueAndExtractUserIdClaimsRoundTrip() { + // given + Integer expectedUserId = 12345; + + // when + String token = refreshTokenService.issue(expectedUserId); + Integer extractedUserId = refreshTokenService.extractUserId(token); + + // then + assertThat(extractedUserId).isEqualTo(expectedUserId); + } + + private String createToken(Integer userId, String issuer, String tokenType, Instant expiresAt, String secret) + throws JOSEException { + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .issuer(issuer) + .issueTime(Date.from(Instant.now().minusSeconds(10))) + .expirationTime(Date.from(expiresAt)) + .jwtID(UUID.randomUUID().toString()) + .claim("id", userId) + .claim("token_type", tokenType) + .build(); + + SignedJWT jwt = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), claims); + jwt.sign(new MACSigner(secret)); + return jwt.serialize(); + } + + private String createTokenWithoutClaim(Integer userId, String issuer, Instant expiresAt, String secret, + String claimToOmit) + throws JOSEException { + JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder() + .issuer(issuer) + .issueTime(Date.from(Instant.now().minusSeconds(10))) + .expirationTime(Date.from(expiresAt)) + .jwtID(UUID.randomUUID().toString()); + + if (!"id".equals(claimToOmit)) { + builder.claim("id", userId); + } + if (!"token_type".equals(claimToOmit)) { + builder.claim("token_type", "refresh"); + } + + SignedJWT jwt = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), builder.build()); + jwt.sign(new MACSigner(secret)); + return jwt.serialize(); + } + + private void assertInvalidRefreshToken(ThrowingCallable callable) { + assertThatThrownBy(callable::call) + .isInstanceOf(CustomException.class) + .satisfies(exception -> assertThat(((CustomException)exception).getErrorCode()) + .isEqualTo(ApiResponseCode.INVALID_REFRESH_TOKEN)); + } + + @FunctionalInterface + private interface ThrowingCallable { + void call() throws Exception; + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/user/service/SignupTokenServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/user/service/SignupTokenServiceTest.java new file mode 100644 index 000000000..e7a70dc19 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/user/service/SignupTokenServiceTest.java @@ -0,0 +1,292 @@ +package gg.agit.konect.unit.domain.user.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +import java.time.Duration; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import gg.agit.konect.domain.user.enums.Provider; +import gg.agit.konect.domain.user.service.SignupTokenService; +import gg.agit.konect.global.auth.util.SecureTokenGenerator; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.support.ServiceTestSupport; + +class SignupTokenServiceTest extends ServiceTestSupport { + + @Mock + private StringRedisTemplate redis; + + @Mock + private ValueOperations valueOperations; + + @Mock + private SecureTokenGenerator secureTokenGenerator; + + @InjectMocks + private SignupTokenService signupTokenService; + + @Test + @DisplayName("issue는 토큰을 생성하고 claims를 TTL과 함께 저장한다") + void issueStoresSerializedClaimsWithTtl() { + // given + given(redis.opsForValue()).willReturn(valueOperations); + given(secureTokenGenerator.generate()).willReturn("signup-token"); + + // when + String token = signupTokenService.issue( + "user@koreatech.ac.kr", + Provider.GOOGLE, + "provider-123", + "홍길동" + ); + + // then + assertThat(token).isEqualTo("signup-token"); + verify(valueOperations).set( + eq("auth:signup:signup-token"), + eq("user@koreatech.ac.kr|GOOGLE|provider-123|홍길동"), + eq(Duration.ofMinutes(10)) + ); + } + + @Test + @DisplayName("readOrThrow는 providerId와 name이 비어 있으면 null로 복원한다") + void readOrThrowNormalizesBlankProviderIdAndName() { + // given + given(redis.opsForValue()).willReturn(valueOperations); + given(valueOperations.get("auth:signup:signup-token")) + .willReturn("user@koreatech.ac.kr|APPLE||"); + + // when + SignupTokenService.SignupClaims claims = signupTokenService.readOrThrow("signup-token"); + + // then + assertThat(claims.email()).isEqualTo("user@koreatech.ac.kr"); + assertThat(claims.provider()).isEqualTo(Provider.APPLE); + assertThat(claims.providerId()).isNull(); + assertThat(claims.name()).isNull(); + } + + @Test + @DisplayName("readOrThrow는 잘못 직렬화된 토큰이면 INVALID_SIGNUP_TOKEN을 던진다") + void readOrThrowThrowsWhenSerializedClaimsAreInvalid() { + // given + given(redis.opsForValue()).willReturn(valueOperations); + given(valueOperations.get("auth:signup:broken-token")).willReturn("broken|GOOGLE"); + + // when & then + assertInvalidSignupToken(() -> signupTokenService.readOrThrow("broken-token")); + } + + @Test + @DisplayName("consumeOrThrow는 토큰을 한 번만 읽고 삭제한다") + void consumeOrThrowReadsAndDeletesTokenAtomically() { + // given + given(redis.opsForValue()).willReturn(valueOperations); + given(valueOperations.getAndDelete("auth:signup:signup-token")) + .willReturn("user@koreatech.ac.kr|KAKAO|provider-1|코넥트"); + + // when + SignupTokenService.SignupClaims claims = signupTokenService.consumeOrThrow("signup-token"); + + // then + assertThat(claims.email()).isEqualTo("user@koreatech.ac.kr"); + assertThat(claims.provider()).isEqualTo(Provider.KAKAO); + assertThat(claims.providerId()).isEqualTo("provider-1"); + assertThat(claims.name()).isEqualTo("코넥트"); + verify(valueOperations).getAndDelete(eq("auth:signup:signup-token")); + } + + @Test + @DisplayName("issue는 email 또는 provider가 비어 있으면 IllegalArgumentException을 던진다") + void issueRejectsMissingRequiredFields() { + assertThatThrownBy(() -> signupTokenService.issue(" ", Provider.GOOGLE, "provider-1")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("email and provider are required"); + + assertThatThrownBy(() -> signupTokenService.issue("user@koreatech.ac.kr", null, "provider-1")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("email and provider are required"); + } + + @Test + @DisplayName("consumeOrThrow는 빈 토큰이면 Redis를 조회하지 않고 INVALID_SIGNUP_TOKEN을 던진다") + void consumeOrThrowRejectsBlankTokenWithoutRedisLookup() { + // when & then + assertInvalidSignupToken(() -> signupTokenService.consumeOrThrow(" ")); + verifyNoInteractions(redis); + } + + @Test + @DisplayName("issue는 빈 이메일 문자열을 거부한다") + void issueRejectsEmptyEmail() { + assertThatThrownBy(() -> signupTokenService.issue("", Provider.GOOGLE, "provider-1")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("email and provider are required"); + } + + @Test + @DisplayName("deserialize는 빈 파트를 포함한 직렬화된 데이터를 거부한다") + void deserializeRejectsEmptyParts() { + // "email|||" → split 결과: [email, "", "", ""] → provider가 빈 문자열이므로 거부 + given(redis.opsForValue()).willReturn(valueOperations); + given(valueOperations.get("auth:signup:token")).willReturn("email|||"); + + assertInvalidSignupToken(() -> signupTokenService.readOrThrow("token")); + } + + @Test + @DisplayName("deserialize는 4개 초과 파트를 거부한다") + void deserializeRejectsMoreThanFourParts() { + // given + given(redis.opsForValue()).willReturn(valueOperations); + given(valueOperations.get("auth:signup:token")).willReturn("a|b|c|d|e"); + + // when & then + assertInvalidSignupToken(() -> signupTokenService.readOrThrow("token")); + } + + @Test + @DisplayName("deserialize는 빈 이메일 필드를 거부한다") + void deserializeRejectsEmptyEmailField() { + // given + given(redis.opsForValue()).willReturn(valueOperations); + given(valueOperations.get("auth:signup:token")).willReturn("|GOOGLE|provider-1|name"); + + // when & then + assertInvalidSignupToken(() -> signupTokenService.readOrThrow("token")); + } + + @Test + @DisplayName("deserialize는 빈 프로바이더 필드를 거부한다") + void deserializeRejectsEmptyProviderField() { + // given + given(redis.opsForValue()).willReturn(valueOperations); + given(valueOperations.get("auth:signup:token")).willReturn("email||provider-1|name"); + + // when & then + assertInvalidSignupToken(() -> signupTokenService.readOrThrow("token")); + } + + @Test + @DisplayName("deserialize는 유효하지 않은 Provider enum 값을 거부한다") + void deserializeRejectsInvalidProviderValue() { + // given + given(redis.opsForValue()).willReturn(valueOperations); + given(valueOperations.get("auth:signup:token")).willReturn("email|INVALID_PROVIDER|provider-1|name"); + + // when & then + assertInvalidSignupToken(() -> signupTokenService.readOrThrow("token")); + } + + @Test + @DisplayName("readOrThrow는 Redis가 빈 문자열을 반환하면 INVALID_SIGNUP_TOKEN을 던진다") + void readOrThrowRejectsEmptyStringFromRedis() { + // given + given(redis.opsForValue()).willReturn(valueOperations); + given(valueOperations.get("auth:signup:token")).willReturn(""); + + // when & then + assertInvalidSignupToken(() -> signupTokenService.readOrThrow("token")); + } + + @Test + @DisplayName("consumeOrThrow는 Redis가 빈 문자열을 반환하면 INVALID_SIGNUP_TOKEN을 던진다") + void consumeOrThrowRejectsEmptyStringFromRedis() { + // given + given(redis.opsForValue()).willReturn(valueOperations); + given(valueOperations.getAndDelete("auth:signup:token")) + .willReturn(""); + + // when & then + assertInvalidSignupToken(() -> signupTokenService.consumeOrThrow("token")); + } + + @Test + @DisplayName("deserialize는 이메일에 파이프 문자가 포함된 경우 파트 분리 오류로 거부한다") + void deserializeRejectsPipeCharacterInEmail() { + // given + given(redis.opsForValue()).willReturn(valueOperations); + given(valueOperations.get("auth:signup:token")).willReturn("test|email@gmail.com|GOOGLE|provider-id|name"); + + // when & then + assertInvalidSignupToken(() -> signupTokenService.readOrThrow("token")); + } + + @Test + @DisplayName("deserialize는 이름에 파이프 문자가 포함된 경우 5개 파트로 인식하여 거부한다") + void deserializeRejectsPipeCharacterInName() { + // given + given(redis.opsForValue()).willReturn(valueOperations); + given(valueOperations.get("auth:signup:token")).willReturn("email@gmail.com|GOOGLE|provider-id|김|철수"); + + // when & then + assertInvalidSignupToken(() -> signupTokenService.readOrThrow("token")); + } + + @Test + @DisplayName("issue는 4파라미터 오버로드로 name을 포함한 토큰을 발급한다") + void issueWithFourParametersIncludesNameInClaims() { + // given + given(redis.opsForValue()).willReturn(valueOperations); + given(secureTokenGenerator.generate()).willReturn("signup-token"); + + // when + String token = signupTokenService.issue( + "user@koreatech.ac.kr", + Provider.GOOGLE, + "provider-123", + "홍길동" + ); + + // then + assertThat(token).isEqualTo("signup-token"); + verify(valueOperations).set( + eq("auth:signup:signup-token"), + eq("user@koreatech.ac.kr|GOOGLE|provider-123|홍길동"), + eq(Duration.ofMinutes(10)) + ); + } + + @Test + @DisplayName("consumeOrThrow는 4파라미터 issue로 생성된 토큰에서 name을 복원한다") + void consumeOrThrowRestoresNameFromFourParameterIssue() { + // given + given(redis.opsForValue()).willReturn(valueOperations); + given(valueOperations.getAndDelete("auth:signup:signup-token")) + .willReturn("user@koreatech.ac.kr|GOOGLE|provider-123|홍길동"); + + // when + SignupTokenService.SignupClaims claims = signupTokenService.consumeOrThrow("signup-token"); + + // then + assertThat(claims.email()).isEqualTo("user@koreatech.ac.kr"); + assertThat(claims.provider()).isEqualTo(Provider.GOOGLE); + assertThat(claims.providerId()).isEqualTo("provider-123"); + assertThat(claims.name()).isEqualTo("홍길동"); + } + + private void assertInvalidSignupToken(ThrowingCallable callable) { + assertThatThrownBy(callable::call) + .isInstanceOf(CustomException.class) + .satisfies(exception -> assertThat(((CustomException)exception).getErrorCode()) + .isEqualTo(ApiResponseCode.INVALID_SIGNUP_TOKEN)); + } + + @FunctionalInterface + private interface ThrowingCallable { + void call(); + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/user/service/UserActivityServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/user/service/UserActivityServiceTest.java new file mode 100644 index 000000000..f65b55db9 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/user/service/UserActivityServiceTest.java @@ -0,0 +1,154 @@ +package gg.agit.konect.unit.domain.user.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +import java.time.LocalDateTime; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.domain.user.service.UserActivityService; +import gg.agit.konect.support.ServiceTestSupport; +import gg.agit.konect.support.fixture.UserFixture; + +class UserActivityServiceTest extends ServiceTestSupport { + + @Mock + private UserRepository userRepository; + + @InjectMocks + private UserActivityService userActivityService; + + @Test + @DisplayName("updateLastLoginAt은 userId가 null이면 저장소를 호출하지 않는다") + void updateLastLoginAtSkipsWhenUserIdIsNull() { + // when + userActivityService.updateLastLoginAt(null); + + // then + verifyNoInteractions(userRepository); + } + + @Test + @DisplayName("updateLastLoginAt은 마지막 로그인/활동 시각을 함께 갱신한다") + void updateLastLoginAtUpdatesLoginAndActivityTimestamp() { + // given + User user = UserFixture.createUserWithId(1, "2021136001"); + given(userRepository.getById(1)).willReturn(user); + + // when + userActivityService.updateLastLoginAt(1); + + // then + assertThat(user.getLastLoginAt()).isNotNull(); + assertThat(user.getLastActivityAt()).isEqualTo(user.getLastLoginAt()); + } + + @Test + @DisplayName("updateLastActivityAt은 사용자가 존재할 때만 마지막 활동 시각을 갱신한다") + void updateLastActivityAtUpdatesExistingUserOnly() { + // given + User user = UserFixture.createUserWithId(2, "2021136002"); + LocalDateTime loginAt = LocalDateTime.of(2026, 4, 1, 9, 0); + user.updateLastLoginAt(loginAt); + given(userRepository.findById(2)).willReturn(Optional.of(user)); + + // when + userActivityService.updateLastActivityAt(2); + + // then + assertThat(user.getLastLoginAt()).isEqualTo(loginAt); + assertThat(user.getLastActivityAt()).isAfter(loginAt); + } + + @Test + @DisplayName("updateLastActivityAt은 userId가 null이면 조용히 종료하고 저장소를 호출하지 않는다") + void updateLastActivityAtSkipsWhenUserIdIsNull() { + // when + userActivityService.updateLastActivityAt(null); + + // then + verifyNoInteractions(userRepository); + } + + @Test + @DisplayName("updateLastActivityAt은 사용자가 없으면 조용히 종료한다") + void updateLastActivityAtSkipsWhenUserMissing() { + // given + given(userRepository.findById(3)).willReturn(Optional.empty()); + + // when + userActivityService.updateLastActivityAt(3); + + // then + verify(userRepository).findById(3); + verifyNoMoreInteractions(userRepository); + } + + @Test + @DisplayName("updateLastLoginAt은 userId가 0(영)이면 정상 처리된다") + void updateLastLoginAtHandlesZeroUserId() { + // given + User user = UserFixture.createUserWithId(0, "2021136000"); + given(userRepository.getById(0)).willReturn(user); + + // when + userActivityService.updateLastLoginAt(0); + + // then + assertThat(user.getLastLoginAt()).isNotNull(); + assertThat(user.getLastActivityAt()).isEqualTo(user.getLastLoginAt()); + } + + @Test + @DisplayName("updateLastLoginAt은 음수 userId가 있어도 정상 처리된다") + void updateLastLoginAtHandlesNegativeUserId() { + // given + User user = UserFixture.createUserWithId(-1, "2021136001"); + given(userRepository.getById(-1)).willReturn(user); + + // when + userActivityService.updateLastLoginAt(-1); + + // then + assertThat(user.getLastLoginAt()).isNotNull(); + assertThat(user.getLastActivityAt()).isEqualTo(user.getLastLoginAt()); + } + + @Test + @DisplayName("updateLastActivityAt은 userId가 0(영)이면 정상 처리된다") + void updateLastActivityAtHandlesZeroUserId() { + // given + User user = UserFixture.createUserWithId(0, "2021136000"); + given(userRepository.findById(0)).willReturn(Optional.of(user)); + + // when + userActivityService.updateLastActivityAt(0); + + // then + assertThat(user.getLastActivityAt()).isNotNull(); + } + + @Test + @DisplayName("updateLastActivityAt은 음수 userId가 있어도 정상 처리된다") + void updateLastActivityAtHandlesNegativeUserId() { + // given + User user = UserFixture.createUserWithId(-1, "2021136001"); + given(userRepository.findById(-1)).willReturn(Optional.of(user)); + + // when + userActivityService.updateLastActivityAt(-1); + + // then + assertThat(user.getLastActivityAt()).isNotNull(); + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/user/service/UserOAuthAccountServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/user/service/UserOAuthAccountServiceTest.java new file mode 100644 index 000000000..e65917f43 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/user/service/UserOAuthAccountServiceTest.java @@ -0,0 +1,775 @@ +package gg.agit.konect.unit.domain.user.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.tuple; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.core.env.Environment; +import org.springframework.core.env.Profiles; + +import gg.agit.konect.domain.user.dto.OAuthLinkStatusResponse; +import gg.agit.konect.domain.user.enums.Provider; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.model.UserOAuthAccount; +import gg.agit.konect.domain.user.repository.UserOAuthAccountRepository; +import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.domain.user.service.UserOAuthAccountService; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.support.ServiceTestSupport; +import gg.agit.konect.support.fixture.UserFixture; + +class UserOAuthAccountServiceTest extends ServiceTestSupport { + + @Mock + private UserRepository userRepository; + + @Mock + private UserOAuthAccountRepository userOAuthAccountRepository; + + @Mock + private Environment environment; + + @InjectMocks + private UserOAuthAccountService userOAuthAccountService; + + @Test + @DisplayName("getLinkStatus는 모든 provider에 대한 연동 여부를 반환한다") + void getLinkStatusReturnsEveryProvider() { + // given + User user = UserFixture.createUserWithId(1, "2021136001"); + given(userRepository.getById(1)).willReturn(user); + given(userOAuthAccountRepository.findAllByUserId(1)).willReturn(List.of( + UserOAuthAccount.of(user, Provider.GOOGLE, "google-id", "google@konect.test", null), + UserOAuthAccount.of(user, Provider.APPLE, "apple-id", "apple@konect.test", "apple-refresh") + )); + + // when + OAuthLinkStatusResponse response = userOAuthAccountService.getLinkStatus(1); + + // then + assertThat(response.providers()) + .extracting(link -> link.provider(), link -> link.linked()) + .containsExactly( + tuple(Provider.GOOGLE, true), + tuple(Provider.NAVER, false), + tuple(Provider.KAKAO, false), + tuple(Provider.APPLE, true) + ); + } + + @Test + @DisplayName("linkOAuthAccount는 providerId가 비어 있으면 예외를 던진다") + void linkOAuthAccountRejectsBlankProviderId() { + // given + User user = UserFixture.createUserWithId(1, "2021136001"); + given(userRepository.getById(1)).willReturn(user); + + // when & then + assertCustomException( + ApiResponseCode.FAILED_EXTRACT_PROVIDER_ID, + () -> userOAuthAccountService.linkOAuthAccount( + 1, + Provider.GOOGLE, + " ", + "google@konect.test", + null + ) + ); + verify(userOAuthAccountRepository, never()).save(any()); + } + + @Test + @DisplayName("linkPrimaryOAuthAccount는 providerId가 없어도 이메일 기준으로 새 계정을 생성한다") + void linkPrimaryOAuthAccountAllowsBlankProviderId() { + // given + User user = UserFixture.createUserWithId(1, "2021136001"); + given(userOAuthAccountRepository.findAccountByProviderAndOauthEmail(Provider.GOOGLE, "google@konect.test")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findUserByOauthEmailAndProvider("google@konect.test", Provider.GOOGLE)) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findByUserIdAndProvider(1, Provider.GOOGLE)) + .willReturn(Optional.empty()); + + // when + userOAuthAccountService.linkPrimaryOAuthAccount( + user, + Provider.GOOGLE, + " ", + "google@konect.test", + null + ); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(UserOAuthAccount.class); + verify(userOAuthAccountRepository).save(captor.capture()); + assertThat(captor.getValue().getUser()).isEqualTo(user); + assertThat(captor.getValue().getProvider()).isEqualTo(Provider.GOOGLE); + assertThat(captor.getValue().getOauthEmail()).isEqualTo("google@konect.test"); + } + + @Test + @DisplayName("linkOAuthAccount는 비어 있던 providerId와 Apple refresh token을 기존 계정에 채운다") + void linkOAuthAccountUpdatesExistingAccountWhenProviderIdWasMissing() { + // given + User user = UserFixture.createUserWithId(1, "2021136001"); + UserOAuthAccount existingAccount = UserOAuthAccount.of( + user, + Provider.APPLE, + null, + "old@konect.test", + null + ); + given(userRepository.getById(1)).willReturn(user); + given(userOAuthAccountRepository.findAccountByProviderAndProviderId(Provider.APPLE, "apple-provider-id")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findUserByProviderAndProviderId(Provider.APPLE, "apple-provider-id")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findAccountByProviderAndOauthEmail(Provider.APPLE, "new@konect.test")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findUserByOauthEmailAndProvider("new@konect.test", Provider.APPLE)) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findByUserIdAndProvider(1, Provider.APPLE)) + .willReturn(Optional.of(existingAccount)); + + // when + userOAuthAccountService.linkOAuthAccount( + 1, + Provider.APPLE, + "apple-provider-id", + "new@konect.test", + "apple-refresh-token" + ); + + // then + assertThat(existingAccount.getProviderId()).isEqualTo("apple-provider-id"); + assertThat(existingAccount.getOauthEmail()).isEqualTo("new@konect.test"); + assertThat(existingAccount.getAppleRefreshToken()).isEqualTo("apple-refresh-token"); + verify(userOAuthAccountRepository).save(existingAccount); + } + + @Test + @DisplayName("linkOAuthAccount는 이미 다른 providerId가 있으면 충돌 예외를 던진다") + void linkOAuthAccountRejectsConflictingProviderIdOnExistingAccount() { + // given + User user = UserFixture.createUserWithId(1, "2021136001"); + UserOAuthAccount existingAccount = UserOAuthAccount.of( + user, + Provider.GOOGLE, + "existing-provider-id", + "google@konect.test", + null + ); + given(userRepository.getById(1)).willReturn(user); + given(userOAuthAccountRepository.findAccountByProviderAndProviderId(Provider.GOOGLE, "new-provider-id")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findUserByProviderAndProviderId(Provider.GOOGLE, "new-provider-id")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findAccountByProviderAndOauthEmail(Provider.GOOGLE, "google@konect.test")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findUserByOauthEmailAndProvider("google@konect.test", Provider.GOOGLE)) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findByUserIdAndProvider(1, Provider.GOOGLE)) + .willReturn(Optional.of(existingAccount)); + + // when & then + assertCustomException( + ApiResponseCode.OAUTH_PROVIDER_ALREADY_LINKED, + () -> userOAuthAccountService.linkOAuthAccount( + 1, + Provider.GOOGLE, + "new-provider-id", + "google@konect.test", + null + ) + ); + verify(userOAuthAccountRepository, never()).save(existingAccount); + } + + @Test + @DisplayName("linkPrimaryOAuthAccount는 복구 기간이 지난 탈퇴 계정을 정리하고 새 계정을 저장한다") + void linkPrimaryOAuthAccountDeletesExpiredWithdrawnAccountBeforeSavingReplacement() { + // given + User currentUser = UserFixture.createUserWithId(1, "2021136001"); + User withdrawnUser = UserFixture.createWithdrawnUser(2, "2020136002", LocalDateTime.now().minusDays(10)); + UserOAuthAccount withdrawnAccount = UserOAuthAccount.of( + withdrawnUser, + Provider.GOOGLE, + "expired-provider-id", + "old@konect.test", + null + ); + given(environment.acceptsProfiles(any(Profiles.class))).willReturn(false); + given(userOAuthAccountRepository.findAccountByProviderAndProviderId(Provider.GOOGLE, "expired-provider-id")) + .willReturn(Optional.of(withdrawnAccount)); + given(userOAuthAccountRepository.findUserByProviderAndProviderId(Provider.GOOGLE, "expired-provider-id")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findAccountByProviderAndOauthEmail(Provider.GOOGLE, "new@konect.test")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findUserByOauthEmailAndProvider("new@konect.test", Provider.GOOGLE)) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findByUserIdAndProvider(1, Provider.GOOGLE)) + .willReturn(Optional.empty()); + + // when + userOAuthAccountService.linkPrimaryOAuthAccount( + currentUser, + Provider.GOOGLE, + "expired-provider-id", + "new@konect.test", + null + ); + + // then + verify(userOAuthAccountRepository).delete(withdrawnAccount); + verify(userOAuthAccountRepository).flush(); + verify(userRepository, never()).save(withdrawnUser); + verify(userOAuthAccountRepository).save(any(UserOAuthAccount.class)); + } + + @Test + @DisplayName("cleanupExpiredWithdrawnUserOAuthAccounts는 임계 시각으로 삭제 후 flush한다") + void cleanupExpiredWithdrawnUserOAuthAccountsDeletesUsingThreshold() { + // given + LocalDateTime now = LocalDateTime.of(2026, 4, 10, 9, 30); + given(userOAuthAccountRepository.deleteAllByWithdrawnUsersBefore(now.minusDays(7))).willReturn(3); + + // when + int deletedCount = userOAuthAccountService.cleanupExpiredWithdrawnUserOAuthAccounts(now); + + // then + assertThat(deletedCount).isEqualTo(3); + verify(userOAuthAccountRepository).deleteAllByWithdrawnUsersBefore(now.minusDays(7)); + verify(userOAuthAccountRepository).flush(); + } + + @Test + @DisplayName("getLinkStatus는 OAuth 계정이 없으면 빈 리스트를 반환한다") + void getLinkStatusReturnsEmptyListWhenNoOAuthAccounts() { + // given + User user = UserFixture.createUserWithId(1, "2021136001"); + given(userRepository.getById(1)).willReturn(user); + given(userOAuthAccountRepository.findAllByUserId(1)).willReturn(List.of()); + + // when + OAuthLinkStatusResponse response = userOAuthAccountService.getLinkStatus(1); + + // then + assertThat(response.providers()) + .extracting(link -> link.provider(), link -> link.linked()) + .containsExactly( + tuple(Provider.GOOGLE, false), + tuple(Provider.NAVER, false), + tuple(Provider.KAKAO, false), + tuple(Provider.APPLE, false) + ); + } + + @Test + @DisplayName("linkPrimaryOAuthAccount는 providerId가 null이고 requireProviderId=false인 경우 성공한다") + void linkPrimaryOAuthAccountAllowsNullProviderIdWhenNotRequired() { + // given + User user = UserFixture.createUserWithId(1, "2021136001"); + given(userOAuthAccountRepository.findAccountByProviderAndOauthEmail(Provider.GOOGLE, "google@konect.test")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findUserByOauthEmailAndProvider("google@konect.test", Provider.GOOGLE)) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findByUserIdAndProvider(1, Provider.GOOGLE)) + .willReturn(Optional.empty()); + + // when + userOAuthAccountService.linkPrimaryOAuthAccount( + user, + Provider.GOOGLE, + null, + "google@konect.test", + null + ); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(UserOAuthAccount.class); + verify(userOAuthAccountRepository).save(captor.capture()); + assertThat(captor.getValue().getUser()).isEqualTo(user); + assertThat(captor.getValue().getProvider()).isEqualTo(Provider.GOOGLE); + assertThat(captor.getValue().getOauthEmail()).isEqualTo("google@konect.test"); + assertThat(captor.getValue().getProviderId()).isNull(); + } + + @Test + @DisplayName("linkOAuthAccount는 providerId와 oauthEmail 모두 제공된 경우 성공한다") + void linkOAuthAccountAcceptsBothProviderIdAndOauthEmail() { + // given + User user = UserFixture.createUserWithId(1, "2021136001"); + given(userRepository.getById(1)).willReturn(user); + given(userOAuthAccountRepository.findAccountByProviderAndProviderId(Provider.GOOGLE, "google-provider-id")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findUserByProviderAndProviderId(Provider.GOOGLE, "google-provider-id")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findAccountByProviderAndOauthEmail(Provider.GOOGLE, "google@konect.test")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findUserByOauthEmailAndProvider("google@konect.test", Provider.GOOGLE)) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findByUserIdAndProvider(1, Provider.GOOGLE)) + .willReturn(Optional.empty()); + + // when + userOAuthAccountService.linkOAuthAccount( + 1, + Provider.GOOGLE, + "google-provider-id", + "google@konect.test", + null + ); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(UserOAuthAccount.class); + verify(userOAuthAccountRepository).save(captor.capture()); + assertThat(captor.getValue().getProviderId()).isEqualTo("google-provider-id"); + assertThat(captor.getValue().getOauthEmail()).isEqualTo("google@konect.test"); + } + + @Test + @DisplayName("linkOAuthAccount는 providerId와 oauthEmail 모두 누락된 경우 예외를 던진다") + void linkOAuthAccountRejectsWhenBothProviderIdAndOauthEmailMissing() { + // given + User user = UserFixture.createUserWithId(1, "2021136001"); + given(userRepository.getById(1)).willReturn(user); + + // when & then + assertCustomException( + ApiResponseCode.FAILED_EXTRACT_PROVIDER_ID, + () -> userOAuthAccountService.linkOAuthAccount( + 1, + Provider.GOOGLE, + null, + null, + null + ) + ); + verify(userOAuthAccountRepository, never()).save(any()); + } + + @Test + @DisplayName("linkOAuthAccount는 oauthEmail이 빈 문자열인 경우에도 성공한다") + void linkOAuthAccountAcceptsEmptyOauthEmail() { + // given + User user = UserFixture.createUserWithId(1, "2021136001"); + given(userRepository.getById(1)).willReturn(user); + given(userOAuthAccountRepository.findAccountByProviderAndProviderId(Provider.GOOGLE, "google-provider-id")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findUserByProviderAndProviderId(Provider.GOOGLE, "google-provider-id")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findByUserIdAndProvider(1, Provider.GOOGLE)) + .willReturn(Optional.empty()); + + // when + userOAuthAccountService.linkOAuthAccount( + 1, + Provider.GOOGLE, + "google-provider-id", + "", + null + ); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(UserOAuthAccount.class); + verify(userOAuthAccountRepository).save(captor.capture()); + assertThat(captor.getValue().getProviderId()).isEqualTo("google-provider-id"); + assertThat(captor.getValue().getOauthEmail()).isEmpty(); + } + + @Test + @DisplayName("restoreOrCleanupWithdrawnByLinkedProvider는 계정이 있지만 사용자가 탈퇴하지 않은 경우 아무것도 하지 않는다") + void restoreOrCleanupWithdrawnByLinkedProviderDoesNothingWhenUserNotWithdrawn() { + // given + User activeUser = UserFixture.createUserWithId(1, "2021136001"); + UserOAuthAccount existingAccount = UserOAuthAccount.of( + activeUser, + Provider.GOOGLE, + "google-provider-id", + "google@konect.test", + null + ); + // activeUser는 deletedAt이 null이므로 restoreOrCleanupWithdrawnByLinkedProvider에서 + // isStageProfile()이 호출되지 않음 — environment stub 불필요 + given(userOAuthAccountRepository.findAccountByProviderAndProviderId(Provider.GOOGLE, "google-provider-id")) + .willReturn(Optional.of(existingAccount)); + given(userOAuthAccountRepository.findUserByProviderAndProviderId(Provider.GOOGLE, "google-provider-id")) + .willReturn(Optional.of(activeUser)); + given(userOAuthAccountRepository.findAccountByProviderAndOauthEmail(Provider.GOOGLE, "new@konect.test")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findUserByOauthEmailAndProvider("new@konect.test", Provider.GOOGLE)) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findByUserIdAndProvider(1, Provider.GOOGLE)) + .willReturn(Optional.empty()); + + // when + userOAuthAccountService.linkPrimaryOAuthAccount( + activeUser, + Provider.GOOGLE, + "google-provider-id", + "new@konect.test", + null + ); + + // then + verify(userRepository, never()).save(any(User.class)); + verify(userOAuthAccountRepository, never()).delete(any(UserOAuthAccount.class)); + } + + @Test + @DisplayName("restoreOrCleanupWithdrawnByLinkedProvider는 복구 기간 내인 경우 사용자를 복구한다") + void restoreOrCleanupWithdrawnByLinkedProviderRestoresUserWithinWindow() { + // given + User currentUser = UserFixture.createUserWithId(1, "2021136001"); + User withdrawnUser = UserFixture.createWithdrawnUser(2, "2020136002", LocalDateTime.now().minusDays(3)); + UserOAuthAccount withdrawnAccount = UserOAuthAccount.of( + withdrawnUser, + Provider.GOOGLE, + "google-provider-id", + "old@konect.test", + null + ); + given(environment.acceptsProfiles(any(Profiles.class))).willReturn(false); + given(userOAuthAccountRepository.findAccountByProviderAndProviderId(Provider.GOOGLE, "google-provider-id")) + .willReturn(Optional.of(withdrawnAccount)); + given(userOAuthAccountRepository.findUserByProviderAndProviderId(Provider.GOOGLE, "google-provider-id")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findAccountByProviderAndOauthEmail(Provider.GOOGLE, "new@konect.test")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findUserByOauthEmailAndProvider("new@konect.test", Provider.GOOGLE)) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findByUserIdAndProvider(1, Provider.GOOGLE)) + .willReturn(Optional.empty()); + + // when + userOAuthAccountService.linkPrimaryOAuthAccount( + currentUser, + Provider.GOOGLE, + "google-provider-id", + "new@konect.test", + null + ); + + // then + assertThat(withdrawnUser.getDeletedAt()).isNull(); + verify(userRepository).save(withdrawnUser); + verify(userOAuthAccountRepository, never()).delete(any(UserOAuthAccount.class)); + } + + @Test + @DisplayName("restoreOrCleanupWithdrawnByOauthEmail는 복구 기간 내인 경우 사용자를 복구한다") + void restoreOrCleanupWithdrawnByOauthEmailRestoresUserWithinWindow() { + // given + User currentUser = UserFixture.createUserWithId(1, "2021136001"); + User withdrawnUser = UserFixture.createWithdrawnUser(2, "2020136002", LocalDateTime.now().minusDays(3)); + UserOAuthAccount withdrawnAccount = UserOAuthAccount.of( + withdrawnUser, + Provider.GOOGLE, + "google-provider-id", + "old@konect.test", + null + ); + given(environment.acceptsProfiles(any(Profiles.class))).willReturn(false); + given(userOAuthAccountRepository.findAccountByProviderAndOauthEmail(Provider.GOOGLE, "old@konect.test")) + .willReturn(Optional.of(withdrawnAccount)); + given(userOAuthAccountRepository.findUserByOauthEmailAndProvider("old@konect.test", Provider.GOOGLE)) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findAccountByProviderAndProviderId(Provider.GOOGLE, "google-provider-id")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findUserByProviderAndProviderId(Provider.GOOGLE, "google-provider-id")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findByUserIdAndProvider(1, Provider.GOOGLE)) + .willReturn(Optional.empty()); + + // when + userOAuthAccountService.linkPrimaryOAuthAccount( + currentUser, + Provider.GOOGLE, + "google-provider-id", + "old@konect.test", + null + ); + + // then + assertThat(withdrawnUser.getDeletedAt()).isNull(); + verify(userRepository).save(withdrawnUser); + verify(userOAuthAccountRepository, never()).delete(any(UserOAuthAccount.class)); + } + + @Test + @DisplayName("getPrimaryOAuthAccount는 계정이 없는 경우 null을 반환한다") + void getPrimaryOAuthAccountReturnsNullWhenNoAccounts() { + // given + given(userOAuthAccountRepository.findAllByUserId(1)).willReturn(List.of()); + + // when + UserOAuthAccount result = userOAuthAccountService.getPrimaryOAuthAccount(1); + + // then + assertThat(result).isNull(); + } + + @Test + @DisplayName("Stage profile에서 탈퇴 계정은 복구하지 않고 삭제된다") + void stageProfileDeletesWithdrawnAccountWithoutRestore() { + // given + User currentUser = UserFixture.createUserWithId(1, "2021136001"); + User withdrawnUser = UserFixture.createWithdrawnUser(2, "2020136002", LocalDateTime.now().minusDays(3)); + UserOAuthAccount withdrawnAccount = UserOAuthAccount.of( + withdrawnUser, + Provider.GOOGLE, + "google-provider-id", + "old@konect.test", + null + ); + given(environment.acceptsProfiles(any(Profiles.class))).willReturn(true); + given(userOAuthAccountRepository.findAccountByProviderAndProviderId(Provider.GOOGLE, "google-provider-id")) + .willReturn(Optional.of(withdrawnAccount)); + given(userOAuthAccountRepository.findUserByProviderAndProviderId(Provider.GOOGLE, "google-provider-id")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findAccountByProviderAndOauthEmail(Provider.GOOGLE, "new@konect.test")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findUserByOauthEmailAndProvider("new@konect.test", Provider.GOOGLE)) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findByUserIdAndProvider(1, Provider.GOOGLE)) + .willReturn(Optional.empty()); + + // when + userOAuthAccountService.linkPrimaryOAuthAccount( + currentUser, + Provider.GOOGLE, + "google-provider-id", + "new@konect.test", + null + ); + + // then + verify(userOAuthAccountRepository).delete(withdrawnAccount); + verify(userOAuthAccountRepository).flush(); + verify(userRepository, never()).save(withdrawnUser); + } + + @Test + @DisplayName("providerId가 NULL인 기존 계정에 새 providerId 연동 시 다른 사용자가 사용 중이면 충돌 예외 발생") + void linkOAuthAccountRejectsWhenNewProviderIdAlreadyLinkedToDifferentUser() { + // given + User currentUser = UserFixture.createUserWithId(1, "2021136001"); + User otherUser = UserFixture.createUserWithId(2, "2022136002"); + given(userRepository.getById(1)).willReturn(currentUser); + given(userOAuthAccountRepository.findAccountByProviderAndProviderId(Provider.GOOGLE, "new-provider-id")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findUserByProviderAndProviderId(Provider.GOOGLE, "new-provider-id")) + .willReturn(Optional.of(otherUser)); + + // when & then + assertCustomException( + ApiResponseCode.OAUTH_ACCOUNT_ALREADY_LINKED, + () -> userOAuthAccountService.linkOAuthAccount( + 1, + Provider.GOOGLE, + "new-provider-id", + "current@konect.test", + null + ) + ); + verify(userOAuthAccountRepository, never()).save(any()); + } + + @Test + @DisplayName("복구 기간 정확히 7일 경계값에서는 복구 불가능") + void restoreWindowBoundaryAtExactlySevenDaysCannotRestore() { + // given + User currentUser = UserFixture.createUserWithId(1, "2021136001"); + User withdrawnUser = UserFixture.createWithdrawnUser(2, "2020136002", LocalDateTime.now().minusDays(7)); + UserOAuthAccount withdrawnAccount = UserOAuthAccount.of( + withdrawnUser, + Provider.GOOGLE, + "google-provider-id", + "old@konect.test", + null + ); + given(environment.acceptsProfiles(any(Profiles.class))).willReturn(false); + given(userOAuthAccountRepository.findAccountByProviderAndProviderId(Provider.GOOGLE, "google-provider-id")) + .willReturn(Optional.of(withdrawnAccount)); + given(userOAuthAccountRepository.findUserByProviderAndProviderId(Provider.GOOGLE, "google-provider-id")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findAccountByProviderAndOauthEmail(Provider.GOOGLE, "new@konect.test")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findUserByOauthEmailAndProvider("new@konect.test", Provider.GOOGLE)) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findByUserIdAndProvider(1, Provider.GOOGLE)) + .willReturn(Optional.empty()); + + // when + userOAuthAccountService.linkPrimaryOAuthAccount( + currentUser, + Provider.GOOGLE, + "google-provider-id", + "new@konect.test", + null + ); + + // then - 정확히 7일 경과 시 isAfter()가 false를 반환하므로 삭제됨 + verify(userOAuthAccountRepository).delete(withdrawnAccount); + verify(userOAuthAccountRepository).flush(); + verify(userRepository, never()).save(withdrawnUser); + } + + @Test + @DisplayName("providerId와 oauthEmail이 서로 다른 탈퇴 사용자인 경우 모두 정리된다") + void linkOAuthAccountCleansUpBothWithdrawnUsersFromProviderIdAndOauthEmail() { + // given + User currentUser = UserFixture.createUserWithId(1, "2021136001"); + User withdrawnUserByProviderId = UserFixture.createWithdrawnUser(2, "2020136002", + LocalDateTime.now().minusDays(10)); + User withdrawnUserByOauthEmail = UserFixture.createWithdrawnUser(3, "2020136003", + LocalDateTime.now().minusDays(10)); + + UserOAuthAccount accountByProviderId = UserOAuthAccount.of( + withdrawnUserByProviderId, + Provider.GOOGLE, + "google-provider-id", + "old1@konect.test", + null + ); + + UserOAuthAccount accountByOauthEmail = UserOAuthAccount.of( + withdrawnUserByOauthEmail, + Provider.GOOGLE, + "other-provider-id", + "old2@konect.test", + null + ); + + given(environment.acceptsProfiles(any(Profiles.class))).willReturn(false); + given(userOAuthAccountRepository.findAccountByProviderAndProviderId(Provider.GOOGLE, "google-provider-id")) + .willReturn(Optional.of(accountByProviderId)); + given(userOAuthAccountRepository.findUserByProviderAndProviderId(Provider.GOOGLE, "google-provider-id")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findAccountByProviderAndOauthEmail(Provider.GOOGLE, "old2@konect.test")) + .willReturn(Optional.of(accountByOauthEmail)); + given(userOAuthAccountRepository.findUserByOauthEmailAndProvider("old2@konect.test", Provider.GOOGLE)) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findByUserIdAndProvider(1, Provider.GOOGLE)) + .willReturn(Optional.empty()); + + // when + userOAuthAccountService.linkPrimaryOAuthAccount( + currentUser, + Provider.GOOGLE, + "google-provider-id", + "old2@konect.test", + null + ); + + // then + verify(userOAuthAccountRepository).delete(accountByProviderId); + verify(userOAuthAccountRepository).delete(accountByOauthEmail); + verify(userOAuthAccountRepository, times(2)).flush(); + verify(userRepository, never()).save(any(User.class)); + } + + @Test + @DisplayName("기존 계정의 providerId 업데이트 시 다른 providerId로 충돌하면 예외 발생") + void linkOAuthAccountRejectsProviderIdUpdateWhenConflict() { + // given + User user = UserFixture.createUserWithId(1, "2021136001"); + UserOAuthAccount existingAccount = UserOAuthAccount.of( + user, + Provider.GOOGLE, + "old-provider-id", + "google@konect.test", + null + ); + given(userRepository.getById(1)).willReturn(user); + given(userOAuthAccountRepository.findAccountByProviderAndProviderId(Provider.GOOGLE, "new-provider-id")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findUserByProviderAndProviderId(Provider.GOOGLE, "new-provider-id")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findAccountByProviderAndOauthEmail(Provider.GOOGLE, "google@konect.test")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findUserByOauthEmailAndProvider("google@konect.test", Provider.GOOGLE)) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findByUserIdAndProvider(1, Provider.GOOGLE)) + .willReturn(Optional.of(existingAccount)); + + // when & then + assertCustomException( + ApiResponseCode.OAUTH_PROVIDER_ALREADY_LINKED, + () -> userOAuthAccountService.linkOAuthAccount( + 1, + Provider.GOOGLE, + "new-provider-id", + "google@konect.test", + null + ) + ); + verify(userOAuthAccountRepository, never()).save(any()); + } + + @Test + @DisplayName("Apple provider는 appleRefreshToken 업데이트를 호출한다") + void linkOAuthAccountUpdatesAppleRefreshTokenForAppleProvider() { + // given + User user = UserFixture.createUserWithId(1, "2021136001"); + UserOAuthAccount existingAccount = UserOAuthAccount.of( + user, + Provider.APPLE, + "apple-provider-id", + "apple@konect.test", + "old-refresh-token" + ); + given(userRepository.getById(1)).willReturn(user); + given(userOAuthAccountRepository.findAccountByProviderAndProviderId(Provider.APPLE, "apple-provider-id")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findUserByProviderAndProviderId(Provider.APPLE, "apple-provider-id")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findAccountByProviderAndOauthEmail(Provider.APPLE, "apple@konect.test")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findUserByOauthEmailAndProvider("apple@konect.test", Provider.APPLE)) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findByUserIdAndProvider(1, Provider.APPLE)) + .willReturn(Optional.of(existingAccount)); + + // when + userOAuthAccountService.linkOAuthAccount( + 1, + Provider.APPLE, + "apple-provider-id", + "apple@konect.test", + "new-refresh-token" + ); + + // then + assertThat(existingAccount.getAppleRefreshToken()).isEqualTo("new-refresh-token"); + verify(userOAuthAccountRepository).save(existingAccount); + } + + private void assertCustomException(ApiResponseCode expectedErrorCode, ThrowingCallable callable) { + assertThatThrownBy(callable::call) + .isInstanceOf(CustomException.class) + .satisfies(exception -> assertThat(((CustomException)exception).getErrorCode()).isEqualTo( + expectedErrorCode + )); + } + + @FunctionalInterface + private interface ThrowingCallable { + void call(); + } +} diff --git a/src/test/java/gg/agit/konect/unit/global/auth/web/AuthCookieServiceTest.java b/src/test/java/gg/agit/konect/unit/global/auth/web/AuthCookieServiceTest.java new file mode 100644 index 000000000..c1a74b078 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/global/auth/web/AuthCookieServiceTest.java @@ -0,0 +1,281 @@ +package gg.agit.konect.unit.global.auth.web; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.util.ReflectionTestUtils; + +import jakarta.servlet.http.Cookie; + +import gg.agit.konect.global.auth.web.AuthCookieService; + +class AuthCookieServiceTest { + + private AuthCookieService authCookieService; + + @BeforeEach + void setUp() { + authCookieService = new AuthCookieService(); + ReflectionTestUtils.setField(authCookieService, "cookieDomain", "konect.test"); + } + + @Test + @DisplayName("setRefreshToken은 HTTPS 요청에 Secure/SameSite=None 쿠키를 설정한다") + void setRefreshTokenUsesSecureCookieForHttpsRequest() { + // given + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setSecure(true); + MockHttpServletResponse response = new MockHttpServletResponse(); + + // when + authCookieService.setRefreshToken(request, response, "refresh-token", Duration.ofMinutes(5)); + + // then + String setCookie = response.getHeader("Set-Cookie"); + assertThat(setCookie) + .contains("refresh_token=refresh-token") + .contains("Max-Age=300") + .contains("Secure") + .contains("SameSite=None"); + assertCommonCookieAttributes(setCookie); + } + + @Test + @DisplayName("setRefreshToken은 X-Forwarded-Proto=https도 보안 요청으로 취급한다") + void setRefreshTokenTreatsForwardedHttpsAsSecure() { + // given + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("X-Forwarded-Proto", "https"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + // when + authCookieService.setRefreshToken(request, response, "refresh-token", Duration.ofSeconds(30)); + + // then + String setCookie = response.getHeader("Set-Cookie"); + assertThat(setCookie) + .contains("refresh_token=refresh-token") + .contains("Max-Age=30") + .contains("Secure") + .contains("SameSite=None"); + assertCommonCookieAttributes(setCookie); + } + + @Test + @DisplayName("clearSignupToken은 만료된 빈 쿠키를 내려준다") + void clearSignupTokenSetsExpiredEmptyCookie() { + // given + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + // when + authCookieService.clearSignupToken(request, response); + + // then + String setCookie = response.getHeader("Set-Cookie"); + assertThat(setCookie) + .contains("signup_token=") + .contains("Max-Age=0"); + assertCommonCookieAttributes(setCookie); + } + + @Test + @DisplayName("clearRefreshToken은 만료된 빈 쿠키를 내려준다") + void clearRefreshTokenSetsExpiredEmptyCookie() { + // given + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + // when + authCookieService.clearRefreshToken(request, response); + + // then + String setCookie = response.getHeader("Set-Cookie"); + assertThat(setCookie) + .contains("refresh_token=") + .contains("Max-Age=0"); + assertCommonCookieAttributes(setCookie); + } + + @Test + @DisplayName("getCookieValue는 null 쿠키 배열을 처리한다") + void getCookieValueHandlesNullCookieArray() { + // given + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setCookies((Cookie[])null); // 명시적으로 null 설정 + + // when & then + assertThat(authCookieService.getCookieValue(request, "any")).isNull(); + } + + @Test + @DisplayName("getCookieValue는 대상 쿠키를 찾고 없으면 null을 반환한다") + void getCookieValueReturnsMatchingCookieValueOrNull() { + // given + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setCookies( + new Cookie("other", "123"), + new Cookie(AuthCookieService.REFRESH_TOKEN_COOKIE, "refresh-value") + ); + + // when & then + assertThat(authCookieService.getCookieValue(request, AuthCookieService.REFRESH_TOKEN_COOKIE)) + .isEqualTo("refresh-value"); + assertThat(authCookieService.getCookieValue(request, AuthCookieService.SIGNUP_TOKEN_COOKIE)).isNull(); + assertThat(authCookieService.getCookieValue(new MockHttpServletRequest(), "missing")).isNull(); + } + + @Test + @DisplayName("setRefreshToken은 null duration이면 예외를 던진다") + void setRefreshTokenRejectsNullDuration() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setSecure(true); + MockHttpServletResponse response = new MockHttpServletResponse(); + + assertThatThrownBy(() -> authCookieService.setRefreshToken(request, response, "refresh-token", null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("getCookieValue는 쿠키가 없으면 null을 반환한다") + void getCookieValueReturnsNullWhenNoCookies() { + // given + MockHttpServletRequest request = new MockHttpServletRequest(); + // 쿠키를 설정하지 않음 + + // when & then + assertThat(authCookieService.getCookieValue(request, "missing")).isNull(); + } + + @Test + @DisplayName("isSecureRequest는 대소문자 혼합 HTTPS 헤더를 처리한다") + void isSecureRequestHandlesMixedCaseHttpsHeader() { + // given + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("X-Forwarded-Proto", "HTTPS"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + // when + authCookieService.setRefreshToken(request, response, "refresh-token", Duration.ofMinutes(5)); + + // then + String setCookie = response.getHeader("Set-Cookie"); + assertThat(setCookie).contains("Secure").contains("SameSite=None"); + } + + @Test + @DisplayName("isSecureRequest는 X-Forwarded-Proto 헤더가 없고 request.isSecure()가 false이면 비보안으로 처리한다") + void isSecureRequestTreatsMissingHeaderAndNonSecureRequestAsNonSecure() { + // given + MockHttpServletRequest request = new MockHttpServletRequest(); + // X-Forwarded-Proto 헤더 없음 + // request.isSecure() 기본값은 false + MockHttpServletResponse response = new MockHttpServletResponse(); + + // when + authCookieService.setRefreshToken(request, response, "refresh-token", Duration.ofMinutes(5)); + + // then + String setCookie = response.getHeader("Set-Cookie"); + assertThat(setCookie).doesNotContain("Secure"); + assertThat(setCookie).doesNotContain("SameSite=None"); + } + + @Test + @DisplayName("setSignupToken은 정상 동작한다") + void setSignupTokenWorksNormally() { + // given + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setSecure(true); + MockHttpServletResponse response = new MockHttpServletResponse(); + + // when + authCookieService.setSignupToken(request, response, "signup-token", Duration.ofMinutes(5)); + + // then + String setCookie = response.getHeader("Set-Cookie"); + assertThat(setCookie) + .contains("signup_token=signup-token") + .contains("Max-Age=300") + .contains("Secure") + .contains("SameSite=None"); + assertCommonCookieAttributes(setCookie); + } + + @Test + @DisplayName("setSignupToken과 clearSignupToken은 쿠키를 설정하고 제거한다") + void setAndClearSignupTokenWorkTogether() { + // given + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setSecure(true); + MockHttpServletResponse response = new MockHttpServletResponse(); + + // when - 설정 + authCookieService.setSignupToken(request, response, "signup-token", Duration.ofMinutes(5)); + String setCookie = response.getHeader("Set-Cookie"); + + // then - 설정 확인 + assertThat(setCookie) + .contains("signup_token=signup-token") + .contains("Max-Age=300"); + + // when - 제거 + MockHttpServletResponse clearResponse = new MockHttpServletResponse(); + authCookieService.clearSignupToken(request, clearResponse); + String clearCookie = clearResponse.getHeader("Set-Cookie"); + + // then - 제거 확인 + assertThat(clearCookie) + .contains("signup_token=") + .contains("Max-Age=0"); + } + + @Test + @DisplayName("X-Forwarded-Proto 복수 값은 전체 문자열과 비교하므로 https가 아니면 비보안 처리한다") + void xForwardedProtoMultipleValuesTreatsEntireString() { + // given + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("X-Forwarded-Proto", "https,http"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + // when + authCookieService.setRefreshToken(request, response, "refresh-token", Duration.ofMinutes(5)); + + // then - 현재 구현에서는 전체 문자열과 비교하므로 "https,http"는 "https"와 다름 + String setCookie = response.getHeader("Set-Cookie"); + // equalsIgnoreCase("https")는 false이므로 비보안 처리 + assertThat(setCookie).doesNotContain("Secure"); + assertThat(setCookie).doesNotContain("SameSite=None"); + } + + @Test + @DisplayName("X-Forwarded-Proto 복수 값은 첫 번째 값이 http이면 비보안 처리한다") + void xForwardedProtoMultipleValuesTreatsHttpAsNonSecure() { + // given + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("X-Forwarded-Proto", "http,https"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + // when + authCookieService.setRefreshToken(request, response, "refresh-token", Duration.ofMinutes(5)); + + // then - 첫 번째 값이 http이므로 비보안 처리 + String setCookie = response.getHeader("Set-Cookie"); + assertThat(setCookie).doesNotContain("Secure"); + assertThat(setCookie).doesNotContain("SameSite=None"); + } + + private void assertCommonCookieAttributes(String setCookie) { + assertThat(setCookie) + .contains("Domain=konect.test") + .contains("Path=/") + .contains("HttpOnly"); + } +} diff --git a/src/test/java/gg/agit/konect/unit/global/ratelimit/aspect/RateLimitAspectTest.java b/src/test/java/gg/agit/konect/unit/global/ratelimit/aspect/RateLimitAspectTest.java new file mode 100644 index 000000000..e368d8522 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/global/ratelimit/aspect/RateLimitAspectTest.java @@ -0,0 +1,156 @@ +package gg.agit.konect.unit.global.ratelimit.aspect; + +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.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import java.util.List; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.reflect.MethodSignature; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; + +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.global.ratelimit.annotation.RateLimit; +import gg.agit.konect.global.ratelimit.aspect.RateLimitAspect; + +@ExtendWith(MockitoExtension.class) +class RateLimitAspectTest { + + @Mock + private StringRedisTemplate redisTemplate; + + @Mock + private ProceedingJoinPoint joinPoint; + + @Mock + private MethodSignature methodSignature; + + @Mock + private RateLimit rateLimit; + + @InjectMocks + private RateLimitAspect rateLimitAspect; + + @SuppressWarnings("unchecked") + @Test + @DisplayName("제한 내 요청 시 정상 통과") + void allowsRequestWithinLimit() throws Throwable { + // given + given(rateLimit.maxRequests()).willReturn(10); + given(rateLimit.timeWindowSeconds()).willReturn(60); + given(rateLimit.keyExpression()).willReturn("#userId"); + given(joinPoint.getSignature()).willReturn(methodSignature); + given(methodSignature.getDeclaringTypeName()).willReturn("test"); + given(methodSignature.getName()).willReturn("method"); + given(methodSignature.getParameterNames()).willReturn(new String[] {"userId"}); + given(joinPoint.getArgs()).willReturn(new Object[] {"user123"}); + + // Lua 스크립트 실행 결과 모킹 + when(redisTemplate.execute(any(DefaultRedisScript.class), any(List.class), any(String.class))) + .thenReturn(5L); + + Object expectedResult = new Object(); + given(joinPoint.proceed()).willReturn(expectedResult); + + // when + Object result = rateLimitAspect.around(joinPoint, rateLimit); + + // then + assertThat(result).isEqualTo(expectedResult); + } + + @SuppressWarnings("unchecked") + @Test + @DisplayName("Lua 스크립트로 원자적 INCR + TTL 설정") + void usesLuaScriptForAtomicIncrAndTtl() throws Throwable { + // given + given(rateLimit.maxRequests()).willReturn(10); + given(rateLimit.timeWindowSeconds()).willReturn(60); + given(rateLimit.keyExpression()).willReturn("#userId"); + given(joinPoint.getSignature()).willReturn(methodSignature); + given(methodSignature.getDeclaringTypeName()).willReturn("test"); + given(methodSignature.getName()).willReturn("method"); + given(methodSignature.getParameterNames()).willReturn(new String[] {"userId"}); + given(joinPoint.getArgs()).willReturn(new Object[] {"user123"}); + given(joinPoint.proceed()).willReturn(null); + + when(redisTemplate.execute(any(DefaultRedisScript.class), any(List.class), any(String.class))) + .thenReturn(1L); + + // when + rateLimitAspect.around(joinPoint, rateLimit); + + // then - Lua 스크립트가 실행되었는지 검증 + verify(redisTemplate).execute(any(DefaultRedisScript.class), any(List.class), eq("60")); + } + + @SuppressWarnings("unchecked") + @Test + @DisplayName("제한 초과 시 RateLimitExceededException 발생") + void throwsExceptionWhenLimitExceeded() { + // given + given(rateLimit.maxRequests()).willReturn(10); + given(rateLimit.timeWindowSeconds()).willReturn(60); + given(rateLimit.keyExpression()).willReturn("#userId"); + given(joinPoint.getSignature()).willReturn(methodSignature); + given(methodSignature.getDeclaringTypeName()).willReturn("test"); + given(methodSignature.getName()).willReturn("method"); + given(methodSignature.getParameterNames()).willReturn(new String[] {"userId"}); + given(joinPoint.getArgs()).willReturn(new Object[] {"user123"}); + + // Lua 스크립트가 카운터를 11로 반환 (제한 초과) + when(redisTemplate.execute(any(DefaultRedisScript.class), any(List.class), any(String.class))) + .thenReturn(11L); + given(redisTemplate.getExpire("ratelimit:test.method:user123")).willReturn(30L); + + // when & then + assertThatThrownBy(() -> rateLimitAspect.around(joinPoint, rateLimit)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> { + CustomException customEx = (CustomException)ex; + assertThat(customEx.getErrorCode()).isEqualTo(ApiResponseCode.TOO_MANY_REQUESTS); + }); + } + + @SuppressWarnings("unchecked") + @Test + @DisplayName("빈 keyExpression 시 메서드 시그니처 기본 키 사용") + void usesDefaultKeyWhenExpressionIsEmpty() throws Throwable { + // given + given(rateLimit.maxRequests()).willReturn(10); + given(rateLimit.timeWindowSeconds()).willReturn(60); + given(rateLimit.keyExpression()).willReturn(""); + given(joinPoint.getSignature()).willReturn(methodSignature); + given(methodSignature.getDeclaringTypeName()).willReturn("test"); + given(methodSignature.getName()).willReturn("method"); + given(joinPoint.proceed()).willReturn(null); + + when(redisTemplate.execute(any(DefaultRedisScript.class), any(List.class), any(String.class))) + .thenReturn(1L); + + // when + rateLimitAspect.around(joinPoint, rateLimit); + + // then - 기본 키로 Lua 스크립트 실행 + verify(redisTemplate).execute( + any(DefaultRedisScript.class), + eq(Collections.singletonList("ratelimit:test.method")), + any(String.class) + ); + } + +} diff --git a/src/test/java/gg/agit/konect/unit/infrastructure/slack/ai/SlackEventControllerTest.java b/src/test/java/gg/agit/konect/unit/infrastructure/slack/ai/SlackEventControllerTest.java new file mode 100644 index 000000000..ee84e68d8 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/infrastructure/slack/ai/SlackEventControllerTest.java @@ -0,0 +1,176 @@ +package gg.agit.konect.unit.infrastructure.slack.ai; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +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; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import gg.agit.konect.infrastructure.slack.ai.SlackAIService; +import gg.agit.konect.infrastructure.slack.ai.SlackEventController; +import gg.agit.konect.infrastructure.slack.config.SlackSignatureVerifier; +import gg.agit.konect.support.ControllerTestSupport; + +@WebMvcTest(SlackEventController.class) +@WithMockUser +class SlackEventControllerTest extends ControllerTestSupport { + + @MockitoBean + private SlackAIService slackAIService; + + @MockitoBean + private SlackSignatureVerifier slackSignatureVerifier; + + @Nested + @DisplayName("POST /slack/events - Slack 이벤트 처리") + class HandleSlackEvent { + + @Test + @DisplayName("url_verification 요청은 서명 검증 없이 challenge를 반환한다") + void handleUrlVerification() throws Exception { + String rawBody = """ + { + "type": "url_verification", + "challenge": "challenge-token" + } + """; + + mockMvc.perform(post("/slack/events") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(rawBody)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.challenge").value("challenge-token")); + + verify(slackSignatureVerifier, never()).isValidRequest(any(), any(), any()); + } + + @Test + @DisplayName("같은 event_id의 중복 이벤트는 한 번만 처리한다") + void ignoresDuplicateEventId() throws Exception { + given(slackSignatureVerifier.isValidRequest(any(), any(), any())).willReturn(true); + given(slackAIService.isAIQuery("AI) 중복 처리 확인")).willReturn(true); + + String rawBody = """ + { + "type": "event_callback", + "event_id": "event-1", + "event": { + "type": "message", + "text": "AI) 중복 처리 확인", + "channel": "C123", + "ts": "1710000000.000100" + } + } + """; + + mockMvc.perform(post("/slack/events") + .with(csrf()) + .header("X-Slack-Request-Timestamp", "1710000000") + .header("X-Slack-Signature", "v0=test") + .contentType(MediaType.APPLICATION_JSON) + .content(rawBody)) + .andExpect(status().isOk()); + + mockMvc.perform(post("/slack/events") + .with(csrf()) + .header("X-Slack-Request-Timestamp", "1710000000") + .header("X-Slack-Signature", "v0=test") + .contentType(MediaType.APPLICATION_JSON) + .content(rawBody)) + .andExpect(status().isOk()); + + verify(slackAIService, times(1)) + .processAIQuery("AI) 중복 처리 확인", "C123", "1710000000.000100", null); + } + + @Test + @DisplayName("스레드의 app_mention은 기존 AI 스레드 답글을 함께 전달한다") + void appMentionWithThreadRepliesPassesContext() throws Exception { + given(slackSignatureVerifier.isValidRequest(any(), any(), any())).willReturn(true); + given(slackAIService.normalizeAppMentionText("<@U123> 이번 주 현황 알려줘")) + .willReturn("이번 주 현황 알려줘"); + + List> aiReplies = List.of( + Map.of("text", "<@U123> 지난주 요약", "ts", "1710000000.000100"), + Map.of("bot_id", "B123", "text", ":robot_face: *AI 응답*\n지난주 답변", "ts", "1710000000.000200") + ); + given(slackAIService.fetchAIThreadReplies("C123", "1710000000.000100")).willReturn(aiReplies); + + String rawBody = """ + { + "type": "event_callback", + "event_id": "event-thread-1", + "event": { + "type": "app_mention", + "text": "<@U123> 이번 주 현황 알려줘", + "channel": "C123", + "ts": "1710000000.000300", + "thread_ts": "1710000000.000100" + } + } + """; + + mockMvc.perform(post("/slack/events") + .with(csrf()) + .header("X-Slack-Request-Timestamp", "1710000001") + .header("X-Slack-Signature", "v0=test") + .contentType(MediaType.APPLICATION_JSON) + .content(rawBody)) + .andExpect(status().isOk()); + + verify(slackAIService).processAIQuery( + "이번 주 현황 알려줘", + "C123", + "1710000000.000100", + aiReplies + ); + } + + @Test + @DisplayName("subtype이 있는 message 이벤트는 무시한다") + void ignoresSubtypeMessage() throws Exception { + given(slackSignatureVerifier.isValidRequest(any(), any(), any())).willReturn(true); + + String rawBody = """ + { + "type": "event_callback", + "event_id": "event-subtype-1", + "event": { + "type": "message", + "subtype": "bot_message", + "text": "AI) 응답하지 말아야 함", + "channel": "C123", + "ts": "1710000000.000400" + } + } + """; + + mockMvc.perform(post("/slack/events") + .with(csrf()) + .header("X-Slack-Request-Timestamp", "1710000002") + .header("X-Slack-Signature", "v0=test") + .contentType(MediaType.APPLICATION_JSON) + .content(rawBody)) + .andExpect(status().isOk()); + + verify(slackAIService, never()).isAIQuery(any()); + verify(slackAIService, never()).processAIQuery(any(), any(), any(), any()); + } + } +} diff --git a/src/test/java/gg/agit/konect/infrastructure/slack/listener/SheetSyncSlackListenerTest.java b/src/test/java/gg/agit/konect/unit/infrastructure/slack/listener/SheetSyncSlackListenerTest.java similarity index 92% rename from src/test/java/gg/agit/konect/infrastructure/slack/listener/SheetSyncSlackListenerTest.java rename to src/test/java/gg/agit/konect/unit/infrastructure/slack/listener/SheetSyncSlackListenerTest.java index 4da3a2168..2b075ec93 100644 --- a/src/test/java/gg/agit/konect/infrastructure/slack/listener/SheetSyncSlackListenerTest.java +++ b/src/test/java/gg/agit/konect/unit/infrastructure/slack/listener/SheetSyncSlackListenerTest.java @@ -1,4 +1,4 @@ -package gg.agit.konect.infrastructure.slack.listener; +package gg.agit.konect.unit.infrastructure.slack.listener; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.verify; @@ -9,6 +9,7 @@ import org.mockito.Mock; import gg.agit.konect.domain.club.event.SheetSyncFailedEvent; +import gg.agit.konect.infrastructure.slack.listener.SheetSyncSlackListener; import gg.agit.konect.infrastructure.slack.service.SlackNotificationService; import gg.agit.konect.support.ServiceTestSupport; diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 9ee862954..4ef81ec03 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -11,10 +11,10 @@ spring: jpa: hibernate: ddl-auto: create-drop - show-sql: true + show-sql: false properties: hibernate: - format_sql: true + format_sql: false dialect: org.hibernate.dialect.H2Dialect flyway: @@ -24,6 +24,8 @@ spring: redis: host: localhost port: 6379 + repositories: + enabled: false security: oauth2: @@ -97,6 +99,8 @@ app: base-url: http://localhost:3000 backend: base-url: http://localhost:8080 + scheduling: + enabled: false cors: allowedOrigins: http://localhost:3000 @@ -141,5 +145,6 @@ logging: ignored-url-patterns: - /**/api-docs/** level: - org.hibernate.SQL: debug - org.hibernate.type.descriptor.sql: trace + org.hibernate.SQL: warn + org.hibernate.type.descriptor.sql: warn + scheduler.studytime: warn