diff --git a/build.gradle.kts b/build.gradle.kts index 59746d63..79139679 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -67,6 +67,7 @@ dependencies { testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.springframework.security:spring-security-test") testImplementation("org.testcontainers:testcontainers:1.19.3") + testImplementation("net.ttddyy:datasource-proxy:1.8.1") testImplementation("org.testcontainers:junit-jupiter:1.19.3") testRuntimeOnly("org.junit.platform:junit-platform-launcher") diff --git a/src/main/java/com/back/domain/board/controller/CommentController.java b/src/main/java/com/back/domain/board/controller/CommentController.java new file mode 100644 index 00000000..a643e294 --- /dev/null +++ b/src/main/java/com/back/domain/board/controller/CommentController.java @@ -0,0 +1,89 @@ +package com.back.domain.board.controller; + +import com.back.domain.board.dto.CommentListResponse; +import com.back.domain.board.dto.CommentRequest; +import com.back.domain.board.dto.CommentResponse; +import com.back.domain.board.dto.PageResponse; +import com.back.domain.board.service.CommentService; +import com.back.global.common.dto.RsData; +import com.back.global.security.user.CustomUserDetails; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/posts/{postId}/comments") +@RequiredArgsConstructor +public class CommentController implements CommentControllerDocs { + private final CommentService commentService; + + // 댓글 생성 + @PostMapping + public ResponseEntity> createComment( + @PathVariable Long postId, + @RequestBody @Valid CommentRequest request, + @AuthenticationPrincipal CustomUserDetails user + ) { + CommentResponse response = commentService.createComment(postId, request, user.getUserId()); + return ResponseEntity + .status(HttpStatus.CREATED) + .body(RsData.success( + "댓글이 생성되었습니다.", + response + )); + } + + // 댓글 다건 조회 + @GetMapping + public ResponseEntity>> getComments( + @PathVariable Long postId, + @PageableDefault(sort = "createdAt", direction = Sort.Direction.ASC) Pageable pageable + ) { + PageResponse response = commentService.getComments(postId, pageable); + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success( + "댓글 목록이 조회되었습니다.", + response + )); + } + + // 댓글 수정 + @PutMapping("/{commentId}") + public ResponseEntity> updateComment( + @PathVariable Long postId, + @PathVariable Long commentId, + @RequestBody @Valid CommentRequest request, + @AuthenticationPrincipal CustomUserDetails user + ) { + CommentResponse response = commentService.updateComment(postId, commentId, request, user.getUserId()); + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success( + "댓글이 수정되었습니다.", + response + )); + } + + // 댓글 삭제 + @DeleteMapping("/{commentId}") + public ResponseEntity> deleteComment( + @PathVariable Long postId, + @PathVariable Long commentId, + @AuthenticationPrincipal CustomUserDetails user + ) { + commentService.deleteComment(postId, commentId, user.getUserId()); + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success( + "댓글이 삭제되었습니다.", + null + )); + } +} diff --git a/src/main/java/com/back/domain/board/controller/CommentControllerDocs.java b/src/main/java/com/back/domain/board/controller/CommentControllerDocs.java new file mode 100644 index 00000000..0b1af463 --- /dev/null +++ b/src/main/java/com/back/domain/board/controller/CommentControllerDocs.java @@ -0,0 +1,512 @@ +package com.back.domain.board.controller; + +import com.back.domain.board.dto.CommentListResponse; +import com.back.domain.board.dto.CommentRequest; +import com.back.domain.board.dto.CommentResponse; +import com.back.domain.board.dto.PageResponse; +import com.back.global.common.dto.RsData; +import com.back.global.security.user.CustomUserDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.data.domain.Pageable; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "Comment API", description = "댓글 관련 API") +public interface CommentControllerDocs { + + @Operation( + summary = "댓글 생성", + description = "로그인한 사용자가 특정 게시글에 댓글을 작성합니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "201", + description = "댓글 생성 성공", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": true, + "code": "SUCCESS_200", + "message": "댓글이 생성되었습니다.", + "data": { + "commentId": 25, + "postId": 101, + "author": { + "id": 5, + "nickname": "홍길동" + }, + "content": "좋은 글 감사합니다!", + "createdAt": "2025-09-22T11:30:00", + "updatedAt": "2025-09-22T11:30:00" + } + } + """) + ) + ), + @ApiResponse( + responseCode = "400", + description = "잘못된 요청 (필드 누락 등)", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "COMMON_400", + "message": "잘못된 요청입니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "401", + description = "인증 실패 (토큰 없음/잘못됨/만료)", + content = @Content( + mediaType = "application/json", + examples = { + @ExampleObject(name = "토큰 없음", value = """ + { + "success": false, + "code": "AUTH_001", + "message": "인증이 필요합니다.", + "data": null + } + """), + @ExampleObject(name = "잘못된 토큰", value = """ + { + "success": false, + "code": "AUTH_002", + "message": "유효하지 않은 액세스 토큰입니다.", + "data": null + } + """), + @ExampleObject(name = "만료된 토큰", value = """ + { + "success": false, + "code": "AUTH_004", + "message": "만료된 액세스 토큰입니다.", + "data": null + } + """) + } + ) + ), + @ApiResponse( + responseCode = "404", + description = "존재하지 않는 사용자 또는 게시글", + content = @Content( + mediaType = "application/json", + examples = { + @ExampleObject(name = "존재하지 않는 사용자", value = """ + { + "success": false, + "code": "USER_001", + "message": "존재하지 않는 사용자입니다.", + "data": null + } + """), + @ExampleObject(name = "존재하지 않는 게시글", value = """ + { + "success": false, + "code": "POST_001", + "message": "존재하지 않는 게시글입니다.", + "data": null + } + """) + } + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "COMMON_500", + "message": "서버 오류가 발생했습니다.", + "data": null + } + """) + ) + ) + }) + ResponseEntity> createComment( + @PathVariable Long postId, + @RequestBody CommentRequest request, + @AuthenticationPrincipal CustomUserDetails user + ); + + @Operation( + summary = "댓글 목록 조회", + description = "특정 게시글에 달린 댓글 목록을 조회합니다. " + + "부모 댓글 기준으로 페이징되며, 각 댓글의 대댓글(children) 목록이 함께 포함됩니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "댓글 목록 조회 성공", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": true, + "code": "SUCCESS_200", + "message": "댓글 목록이 조회되었습니다.", + "data": { + "content": [ + { + "commentId": 1, + "postId": 101, + "parentId": null, + "author": { + "id": 5, + "nickname": "홍길동" + }, + "content": "부모 댓글", + "likeCount": 2, + "createdAt": "2025-09-22T11:30:00", + "updatedAt": "2025-09-22T11:30:00", + "children": [ + { + "commentId": 2, + "postId": 101, + "parentId": 1, + "author": { + "id": 5, + "nickname": "홍길동" + }, + "content": "자식 댓글", + "likeCount": 0, + "createdAt": "2025-09-22T11:35:00", + "updatedAt": "2025-09-22T11:35:00", + "children": [] + } + ] + } + ], + "pageNumber": 0, + "pageSize": 10, + "totalElements": 1, + "totalPages": 1, + "last": true + } + } + """) + ) + ), + @ApiResponse( + responseCode = "400", + description = "잘못된 요청 (파라미터 오류)", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "COMMON_400", + "message": "잘못된 요청입니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "404", + description = "존재하지 않는 게시글", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "POST_001", + "message": "존재하지 않는 게시글입니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "COMMON_500", + "message": "서버 오류가 발생했습니다.", + "data": null + } + """) + ) + ) + }) + ResponseEntity>> getComments( + @PathVariable Long postId, + Pageable pageable + ); + + @Operation( + summary = "댓글 수정", + description = "로그인한 사용자가 자신이 작성한 댓글을 수정합니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "댓글 수정 성공", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": true, + "code": "SUCCESS_200", + "message": "댓글이 수정되었습니다.", + "data": { + "commentId": 25, + "postId": 101, + "author": { + "id": 5, + "nickname": "홍길동" + }, + "content": "수정된 댓글 내용입니다.", + "createdAt": "2025-09-22T11:30:00", + "updatedAt": "2025-09-22T13:00:00" + } + } + """) + ) + ), + @ApiResponse( + responseCode = "400", + description = "잘못된 요청 (필드 누락 등)", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "COMMON_400", + "message": "잘못된 요청입니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "401", + description = "인증 실패 (토큰 없음/잘못됨/만료)", + content = @Content( + mediaType = "application/json", + examples = { + @ExampleObject(name = "토큰 없음", value = """ + { + "success": false, + "code": "AUTH_001", + "message": "인증이 필요합니다.", + "data": null + } + """), + @ExampleObject(name = "잘못된 토큰", value = """ + { + "success": false, + "code": "AUTH_002", + "message": "유효하지 않은 액세스 토큰입니다.", + "data": null + } + """), + @ExampleObject(name = "만료된 토큰", value = """ + { + "success": false, + "code": "AUTH_004", + "message": "만료된 액세스 토큰입니다.", + "data": null + } + """) + } + ) + ), + @ApiResponse( + responseCode = "403", + description = "권한 없음 (작성자 아님)", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "COMMENT_002", + "message": "댓글 작성자만 수정/삭제할 수 있습니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "404", + description = "존재하지 않는 게시글 또는 댓글", + content = @Content( + mediaType = "application/json", + examples = { + @ExampleObject(name = "존재하지 않는 게시글", value = """ + { + "success": false, + "code": "POST_001", + "message": "존재하지 않는 게시글입니다.", + "data": null + } + """), + @ExampleObject(name = "존재하지 않는 댓글", value = """ + { + "success": false, + "code": "COMMENT_001", + "message": "존재하지 않는 댓글입니다.", + "data": null + } + """) + } + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "COMMON_500", + "message": "서버 오류가 발생했습니다.", + "data": null + } + """) + ) + ) + }) + ResponseEntity> updateComment( + @PathVariable Long postId, + @PathVariable Long commentId, + @RequestBody CommentRequest request, + @AuthenticationPrincipal CustomUserDetails user + ); + + @Operation( + summary = "댓글 삭제", + description = "로그인한 사용자가 자신이 작성한 댓글을 삭제합니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "댓글 삭제 성공", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": true, + "code": "SUCCESS_200", + "message": "댓글이 삭제되었습니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "401", + description = "인증 실패 (토큰 없음/잘못됨/만료)", + content = @Content( + mediaType = "application/json", + examples = { + @ExampleObject(name = "토큰 없음", value = """ + { + "success": false, + "code": "AUTH_001", + "message": "인증이 필요합니다.", + "data": null + } + """), + @ExampleObject(name = "잘못된 토큰", value = """ + { + "success": false, + "code": "AUTH_002", + "message": "유효하지 않은 액세스 토큰입니다.", + "data": null + } + """), + @ExampleObject(name = "만료된 토큰", value = """ + { + "success": false, + "code": "AUTH_004", + "message": "만료된 액세스 토큰입니다.", + "data": null + } + """) + } + ) + ), + @ApiResponse( + responseCode = "403", + description = "권한 없음 (작성자 아님)", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "COMMENT_002", + "message": "댓글 작성자만 수정/삭제할 수 있습니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "404", + description = "존재하지 않는 게시글 또는 댓글", + content = @Content( + mediaType = "application/json", + examples = { + @ExampleObject(name = "존재하지 않는 게시글", value = """ + { + "success": false, + "code": "POST_001", + "message": "존재하지 않는 게시글입니다.", + "data": null + } + """), + @ExampleObject(name = "존재하지 않는 댓글", value = """ + { + "success": false, + "code": "COMMENT_001", + "message": "존재하지 않는 댓글입니다.", + "data": null + } + """) + } + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "COMMON_500", + "message": "서버 오류가 발생했습니다.", + "data": null + } + """) + ) + ) + }) + ResponseEntity> deleteComment( + @PathVariable Long postId, + @PathVariable Long commentId, + @AuthenticationPrincipal CustomUserDetails user + ); +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/board/controller/PostController.java b/src/main/java/com/back/domain/board/controller/PostController.java new file mode 100644 index 00000000..fa4c71cf --- /dev/null +++ b/src/main/java/com/back/domain/board/controller/PostController.java @@ -0,0 +1,99 @@ +package com.back.domain.board.controller; + +import com.back.domain.board.dto.*; +import com.back.domain.board.service.PostService; +import com.back.global.common.dto.RsData; +import com.back.global.security.user.CustomUserDetails; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/posts") +@RequiredArgsConstructor +public class PostController implements PostControllerDocs { + private final PostService postService; + + // 게시글 생성 + @PostMapping + public ResponseEntity> createPost( + @RequestBody @Valid PostRequest request, + @AuthenticationPrincipal CustomUserDetails user + ) { + PostResponse response = postService.createPost(request, user.getUserId()); + return ResponseEntity + .status(HttpStatus.CREATED) + .body(RsData.success( + "게시글이 생성되었습니다.", + response + )); + } + + // 게시글 다건 조회 + @GetMapping + public ResponseEntity>> getPosts( + @PageableDefault(sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String searchType, + @RequestParam(required = false) Long categoryId + ) { + PageResponse response = postService.getPosts(keyword, searchType, categoryId, pageable); + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success( + "게시글 목록이 조회되었습니다.", + response + )); + } + + // 게시글 단건 조회 + @GetMapping("/{postId}") + public ResponseEntity> getPost( + @PathVariable Long postId + ) { + PostDetailResponse response = postService.getPost(postId); + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success( + "게시글이 조회되었습니다.", + response + )); + } + + // 게시글 수정 + @PutMapping("/{postId}") + public ResponseEntity> updatePost( + @PathVariable Long postId, + @RequestBody @Valid PostRequest request, + @AuthenticationPrincipal CustomUserDetails user + ) { + PostResponse response = postService.updatePost(postId, request, user.getUserId()); + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success( + "게시글이 수정되었습니다.", + response + )); + } + + // 게시글 삭제 + @DeleteMapping("/{postId}") + public ResponseEntity> deletePost( + @PathVariable Long postId, + @AuthenticationPrincipal CustomUserDetails user + ) { + postService.deletePost(postId, user.getUserId()); + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success( + "게시글이 삭제되었습니다.", + null + )); + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/board/controller/PostControllerDocs.java b/src/main/java/com/back/domain/board/controller/PostControllerDocs.java new file mode 100644 index 00000000..16dcd241 --- /dev/null +++ b/src/main/java/com/back/domain/board/controller/PostControllerDocs.java @@ -0,0 +1,560 @@ +package com.back.domain.board.controller; + +import com.back.domain.board.dto.*; +import com.back.global.common.dto.RsData; +import com.back.global.security.user.CustomUserDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +@Tag(name = "Post API", description = "게시글 관련 API") +public interface PostControllerDocs { + + @Operation( + summary = "게시글 생성", + description = "로그인한 사용자가 새 게시글을 작성합니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "201", + description = "게시글 생성 성공", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": true, + "code": "SUCCESS_200", + "message": "게시글이 생성되었습니다.", + "data": { + "postId": 101, + "author": { + "id": 5, + "nickname": "홍길동" + }, + "title": "첫 번째 게시글", + "content": "안녕하세요, 첫 글입니다!", + "categories": [ + { "id": 1, "name": "공지사항" }, + { "id": 2, "name": "자유게시판" } + ], + "createdAt": "2025-09-22T10:30:00", + "updatedAt": "2025-09-22T10:30:00" + } + } + """) + ) + ), + @ApiResponse( + responseCode = "400", + description = "잘못된 요청 (필드 누락 등)", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "COMMON_400", + "message": "잘못된 요청입니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "401", + description = "인증 실패 (토큰 없음/잘못됨/만료)", + content = @Content( + mediaType = "application/json", + examples = { + @ExampleObject(name = "토큰 없음", value = """ + { + "success": false, + "code": "AUTH_001", + "message": "인증이 필요합니다.", + "data": null + } + """), + @ExampleObject(name = "잘못된 토큰", value = """ + { + "success": false, + "code": "AUTH_002", + "message": "유효하지 않은 액세스 토큰입니다.", + "data": null + } + """), + @ExampleObject(name = "만료된 토큰", value = """ + { + "success": false, + "code": "AUTH_004", + "message": "만료된 액세스 토큰입니다.", + "data": null + } + """) + } + ) + ), + @ApiResponse( + responseCode = "404", + description = "존재하지 않는 사용자 또는 카테고리", + content = @Content( + mediaType = "application/json", + examples = { + @ExampleObject(name = "존재하지 않는 사용자", value = """ + { + "success": false, + "code": "USER_001", + "message": "존재하지 않는 사용자입니다.", + "data": null + } + """), + @ExampleObject(name = "존재하지 않는 카테고리", value = """ + { + "success": false, + "code": "POST_003", + "message": "존재하지 않는 카테고리입니다.", + "data": null + } + """) + } + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "COMMON_500", + "message": "서버 오류가 발생했습니다.", + "data": null + } + """) + ) + ) + }) + ResponseEntity> createPost( + @RequestBody PostRequest request, + @AuthenticationPrincipal CustomUserDetails user + ); + + @Operation( + summary = "게시글 목록 조회", + description = "모든 사용자가 게시글 목록을 조회할 수 있습니다. (로그인 불필요)" + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "게시글 목록 조회 성공", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": true, + "code": "SUCCESS_200", + "message": "게시글 목록이 조회되었습니다.", + "data": { + "items": [ + { + "postId": 1, + "author": { "id": 10, "nickname": "홍길동" }, + "title": "첫 글", + "categories": [{ "id": 1, "name": "공지사항" }], + "likeCount": 5, + "bookmarkCount": 2, + "commentCount": 3, + "createdAt": "2025-09-30T10:15:30", + "updatedAt": "2025-09-30T10:20:00" + } + ], + "page": 0, + "size": 10, + "totalElements": 25, + "totalPages": 3, + "last": false + } + } + """) + ) + ), + @ApiResponse( + responseCode = "400", + description = "잘못된 요청 (페이징 파라미터 오류 등)", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "COMMON_400", + "message": "잘못된 요청입니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "COMMON_500", + "message": "서버 오류가 발생했습니다.", + "data": null + } + """) + ) + ) + }) + ResponseEntity>> getPosts( + @PageableDefault(sort = "createdAt") Pageable pageable, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String searchType, + @RequestParam(required = false) Long categoryId + ); + + + @Operation( + summary = "게시글 단건 조회", + description = "모든 사용자가 특정 게시글의 상세 정보를 조회할 수 있습니다. (로그인 불필요)" + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "게시글 단건 조회 성공", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": true, + "code": "SUCCESS_200", + "message": "게시글이 조회되었습니다.", + "data": { + "postId": 101, + "author": { "id": 5, "nickname": "홍길동" }, + "title": "첫 번째 게시글", + "content": "안녕하세요, 첫 글입니다!", + "categories": [ + { "id": 1, "name": "공지사항" }, + { "id": 2, "name": "자유게시판" } + ], + "likeCount": 10, + "bookmarkCount": 2, + "commentCount": 3, + "createdAt": "2025-09-22T10:30:00", + "updatedAt": "2025-09-22T10:30:00" + } + } + """) + ) + ), + @ApiResponse( + responseCode = "404", + description = "존재하지 않는 게시글", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "POST_001", + "message": "존재하지 않는 게시글입니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "COMMON_500", + "message": "서버 오류가 발생했습니다.", + "data": null + } + """) + ) + ) + }) + ResponseEntity> getPost( + @PathVariable Long postId + ); + + @Operation( + summary = "게시글 수정", + description = "로그인한 사용자가 자신의 게시글을 수정합니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "게시글 수정 성공", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": true, + "code": "SUCCESS_200", + "message": "게시글이 수정되었습니다.", + "data": { + "postId": 101, + "author": { + "id": 5, + "nickname": "홍길동" + }, + "title": "수정된 게시글", + "content": "안녕하세요, 수정했습니다!", + "categories": [ + { "id": 1, "name": "공지사항" }, + { "id": 2, "name": "자유게시판" } + ], + "createdAt": "2025-09-22T10:30:00", + "updatedAt": "2025-09-22T10:30:00" + } + } + """) + ) + ), + @ApiResponse( + responseCode = "400", + description = "잘못된 요청 (필드 누락 등)", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "COMMON_400", + "message": "잘못된 요청입니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "401", + description = "인증 실패 (토큰 없음/만료/잘못됨)", + content = @Content( + mediaType = "application/json", + examples = { + @ExampleObject(name = "토큰 없음", value = """ + { + "success": false, + "code": "AUTH_001", + "message": "인증이 필요합니다.", + "data": null + } + """), + @ExampleObject(name = "잘못된 토큰", value = """ + { + "success": false, + "code": "AUTH_002", + "message": "유효하지 않은 액세스 토큰입니다.", + "data": null + } + """), + @ExampleObject(name = "만료된 토큰", value = """ + { + "success": false, + "code": "AUTH_004", + "message": "만료된 액세스 토큰입니다.", + "data": null + } + """) + } + ) + ), + @ApiResponse( + responseCode = "403", + description = "권한 없음 (작성자 아님)", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "POST_002", + "message": "게시글 작성자만 수정/삭제할 수 있습니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "404", + description = "존재하지 않는 게시글 또는 카테고리", + content = @Content( + mediaType = "application/json", + examples = { + @ExampleObject(name = "존재하지 않는 게시글", value = """ + { + "success": false, + "code": "POST_001", + "message": "존재하지 않는 게시글입니다.", + "data": null + } + """), + @ExampleObject(name = "존재하지 않는 카테고리", value = """ + { + "success": false, + "code": "POST_003", + "message": "존재하지 않는 카테고리입니다.", + "data": null + } + """) + } + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "COMMON_500", + "message": "서버 오류가 발생했습니다.", + "data": null + } + """) + ) + ) + }) + ResponseEntity> updatePost( + @PathVariable Long postId, + @RequestBody PostRequest request, + @AuthenticationPrincipal CustomUserDetails user + ); + + @Operation( + summary = "게시글 삭제", + description = "로그인한 사용자가 자신의 게시글을 삭제합니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "게시글 삭제 성공", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": true, + "code": "SUCCESS_200", + "message": "게시글이 삭제되었습니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "400", + description = "잘못된 요청 (필드 누락 등)", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "COMMON_400", + "message": "잘못된 요청입니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "401", + description = "인증 실패 (토큰 없음/만료/잘못됨)", + content = @Content( + mediaType = "application/json", + examples = { + @ExampleObject(name = "토큰 없음", value = """ + { + "success": false, + "code": "AUTH_001", + "message": "인증이 필요합니다.", + "data": null + } + """), + @ExampleObject(name = "잘못된 토큰", value = """ + { + "success": false, + "code": "AUTH_002", + "message": "유효하지 않은 액세스 토큰입니다.", + "data": null + } + """), + @ExampleObject(name = "만료된 토큰", value = """ + { + "success": false, + "code": "AUTH_004", + "message": "만료된 액세스 토큰입니다.", + "data": null + } + """) + } + ) + ), + @ApiResponse( + responseCode = "403", + description = "권한 없음 (작성자 아님)", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "POST_002", + "message": "게시글 작성자만 수정/삭제할 수 있습니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "404", + description = "존재하지 않는 게시글", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "POST_001", + "message": "존재하지 않는 게시글입니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "COMMON_500", + "message": "서버 오류가 발생했습니다.", + "data": null + } + """) + ) + ) + }) + ResponseEntity> deletePost( + @PathVariable Long postId, + @AuthenticationPrincipal CustomUserDetails user + ); +} diff --git a/src/main/java/com/back/domain/board/dto/AuthorResponse.java b/src/main/java/com/back/domain/board/dto/AuthorResponse.java new file mode 100644 index 00000000..9019a0f1 --- /dev/null +++ b/src/main/java/com/back/domain/board/dto/AuthorResponse.java @@ -0,0 +1,25 @@ +package com.back.domain.board.dto; + +import com.back.domain.user.entity.User; +import com.querydsl.core.annotations.QueryProjection; + +/** + * 작성자 응답 DTO + * + * @param id 작성자 ID + * @param nickname 작성자 닉네임 + */ +public record AuthorResponse( + Long id, + String nickname +) { + @QueryProjection + public AuthorResponse {} + + public static AuthorResponse from(User user) { + return new AuthorResponse( + user.getId(), + user.getUserProfile().getNickname() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/board/dto/CategoryResponse.java b/src/main/java/com/back/domain/board/dto/CategoryResponse.java new file mode 100644 index 00000000..50b3fe4f --- /dev/null +++ b/src/main/java/com/back/domain/board/dto/CategoryResponse.java @@ -0,0 +1,21 @@ +package com.back.domain.board.dto; + +import com.back.domain.board.entity.PostCategory; + +/** + * 카테고리 응답 DTO + * + * @param id 카테고리 ID + * @param name 카테고리 이름 + */ +public record CategoryResponse( + Long id, + String name +) { + public static CategoryResponse from(PostCategory category) { + return new CategoryResponse( + category.getId(), + category.getName() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/board/dto/CommentListResponse.java b/src/main/java/com/back/domain/board/dto/CommentListResponse.java new file mode 100644 index 00000000..4ee2982c --- /dev/null +++ b/src/main/java/com/back/domain/board/dto/CommentListResponse.java @@ -0,0 +1,50 @@ +package com.back.domain.board.dto; + +import com.querydsl.core.annotations.QueryProjection; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 댓글 목록 응답 DTO + */ +@Getter +public class CommentListResponse { + private final Long commentId; + private final Long postId; + private final Long parentId; + private final AuthorResponse author; + private final String content; + + @Setter + private long likeCount; + + private final LocalDateTime createdAt; + private final LocalDateTime updatedAt; + + @Setter + private List children; + + @QueryProjection + public CommentListResponse(Long commentId, + Long postId, + Long parentId, + AuthorResponse author, + String content, + long likeCount, + LocalDateTime createdAt, + LocalDateTime updatedAt, + List children) { + this.commentId = commentId; + this.postId = postId; + this.parentId = parentId; + this.author = author; + this.content = content; + this.likeCount = likeCount; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + this.children = children; + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/board/dto/CommentRequest.java b/src/main/java/com/back/domain/board/dto/CommentRequest.java new file mode 100644 index 00000000..24a1a2eb --- /dev/null +++ b/src/main/java/com/back/domain/board/dto/CommentRequest.java @@ -0,0 +1,13 @@ +package com.back.domain.board.dto; + +import jakarta.validation.constraints.NotBlank; + +/** + * 댓글 작성 및 수정을 위한 요청 DTO + * + * @param content 댓글 내용 + */ +public record CommentRequest( + @NotBlank String content +) { +} diff --git a/src/main/java/com/back/domain/board/dto/CommentResponse.java b/src/main/java/com/back/domain/board/dto/CommentResponse.java new file mode 100644 index 00000000..6b76feba --- /dev/null +++ b/src/main/java/com/back/domain/board/dto/CommentResponse.java @@ -0,0 +1,35 @@ +package com.back.domain.board.dto; + +import com.back.domain.board.entity.Comment; + +import java.time.LocalDateTime; + +/** + * 댓글 응답 DTO + * + * @param commentId 댓글 Id + * @param postId 게시글 Id + * @param author 작성자 정보 + * @param content 댓글 내용 + * @param createdAt 댓글 생성 일시 + * @param updatedAt 댓글 수정 일시 + */ +public record CommentResponse( + Long commentId, + Long postId, + AuthorResponse author, + String content, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { + public static CommentResponse from(Comment comment) { + return new CommentResponse( + comment.getId(), + comment.getPost().getId(), + AuthorResponse.from(comment.getUser()), + comment.getContent(), + comment.getCreatedAt(), + comment.getUpdatedAt() + ); + } +} diff --git a/src/main/java/com/back/domain/board/dto/PageResponse.java b/src/main/java/com/back/domain/board/dto/PageResponse.java new file mode 100644 index 00000000..ef317fb0 --- /dev/null +++ b/src/main/java/com/back/domain/board/dto/PageResponse.java @@ -0,0 +1,36 @@ +package com.back.domain.board.dto; + +import org.springframework.data.domain.Page; + +import java.util.List; + +/** + * 페이지 응답 DTO + * + * @param items 목록 데이터 + * @param page 현재 페이지 번호 + * @param size 페이지 크기 + * @param totalElements 전체 요소 수 + * @param totalPages 전체 페이지 수 + * @param last 마지막 페이지 여부 + * @param 제네릭 타입 + */ +public record PageResponse( + List items, + int page, + int size, + long totalElements, + int totalPages, + boolean last +) { + public static PageResponse from(Page page) { + return new PageResponse<>( + page.getContent(), + page.getNumber(), + page.getSize(), + page.getTotalElements(), + page.getTotalPages(), + page.isLast() + ); + } +} diff --git a/src/main/java/com/back/domain/board/dto/PostDetailResponse.java b/src/main/java/com/back/domain/board/dto/PostDetailResponse.java new file mode 100644 index 00000000..b9dc5198 --- /dev/null +++ b/src/main/java/com/back/domain/board/dto/PostDetailResponse.java @@ -0,0 +1,50 @@ +package com.back.domain.board.dto; + +import com.back.domain.board.entity.Post; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 게시글 상세 응답 DTO + * + * @param postId 게시글 ID + * @param author 작성자 정보 + * @param title 게시글 제목 + * @param content 게시글 내용 + * @param categories 게시글 카테고리 목록 + * @param likeCount 좋아요 수 + * @param bookmarkCount 북마크 수 + * @param commentCount 댓글 수 + * @param createdAt 게시글 생성 일시 + * @param updatedAt 게시글 수정 일시 + */ +public record PostDetailResponse( + Long postId, + AuthorResponse author, + String title, + String content, + List categories, + long likeCount, + long bookmarkCount, + long commentCount, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { + public static PostDetailResponse from(Post post) { + return new PostDetailResponse( + post.getId(), + AuthorResponse.from(post.getUser()), + post.getTitle(), + post.getContent(), + post.getCategories().stream() + .map(CategoryResponse::from) + .toList(), + post.getPostLikes().size(), + post.getPostBookmarks().size(), + post.getComments().size(), + post.getCreatedAt(), + post.getUpdatedAt() + ); + } +} diff --git a/src/main/java/com/back/domain/board/dto/PostListResponse.java b/src/main/java/com/back/domain/board/dto/PostListResponse.java new file mode 100644 index 00000000..2976b9c6 --- /dev/null +++ b/src/main/java/com/back/domain/board/dto/PostListResponse.java @@ -0,0 +1,77 @@ +package com.back.domain.board.dto; + +import com.querydsl.core.annotations.QueryProjection; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 게시글 목록 응답 DTO + */ +@Getter +public class PostListResponse { + private final Long postId; + private final AuthorResponse author; + private final String title; + private final long likeCount; + private final long bookmarkCount; + private final long commentCount; + private final LocalDateTime createdAt; + private final LocalDateTime updatedAt; + + @Setter + private List categories; + + @QueryProjection + public PostListResponse(Long postId, + AuthorResponse author, + String title, + List categories, + long likeCount, + long bookmarkCount, + long commentCount, + LocalDateTime createdAt, + LocalDateTime updatedAt) { + this.postId = postId; + this.author = author; + this.title = title; + this.categories = categories; + this.likeCount = likeCount; + this.bookmarkCount = bookmarkCount; + this.commentCount = commentCount; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + /** + * 작성자 응답 DTO + */ + @Getter + public static class AuthorResponse { + private final Long id; + private final String nickname; + + @QueryProjection + public AuthorResponse(Long userId, String nickname) { + this.id = userId; + this.nickname = nickname; + } + } + + /** + * 카테고리 응답 DTO + */ + @Getter + public static class CategoryResponse { + private final Long id; + private final String name; + + @QueryProjection + public CategoryResponse(Long id, String name) { + this.id = id; + this.name = name; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/board/dto/PostRequest.java b/src/main/java/com/back/domain/board/dto/PostRequest.java new file mode 100644 index 00000000..1b8f655a --- /dev/null +++ b/src/main/java/com/back/domain/board/dto/PostRequest.java @@ -0,0 +1,18 @@ +package com.back.domain.board.dto; + +import jakarta.validation.constraints.NotBlank; + +import java.util.List; + +/** + * 게시글 생성 및 수정을 위한 요청 DTO + * + * @param title 게시글 제목 + * @param content 게시글 내용 + * @param categoryIds 카테고리 ID 리스트 + */ +public record PostRequest( + @NotBlank String title, + @NotBlank String content, + List categoryIds +) {} \ No newline at end of file diff --git a/src/main/java/com/back/domain/board/dto/PostResponse.java b/src/main/java/com/back/domain/board/dto/PostResponse.java new file mode 100644 index 00000000..a253bb4f --- /dev/null +++ b/src/main/java/com/back/domain/board/dto/PostResponse.java @@ -0,0 +1,40 @@ +package com.back.domain.board.dto; + +import com.back.domain.board.entity.Post; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 게시글 응답 DTO + * + * @param postId 게시글 ID + * @param author 작성자 정보 + * @param title 게시글 제목 + * @param content 게시글 내용 + * @param categories 게시글 카테고리 목록 + * @param createdAt 게시글 생성 일시 + * @param updatedAt 게시글 수정 일시 + */ +public record PostResponse( + Long postId, + AuthorResponse author, + String title, + String content, + List categories, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { + public static PostResponse from(Post post) { + return new PostResponse( + post.getId(), + AuthorResponse.from(post.getUser()), + post.getTitle(), + post.getContent(), + post.getCategories().stream() + .map(CategoryResponse::from) + .toList(), + post.getCreatedAt(), + post.getUpdatedAt() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/board/entity/Comment.java b/src/main/java/com/back/domain/board/entity/Comment.java index 8ed14c93..d1e81993 100644 --- a/src/main/java/com/back/domain/board/entity/Comment.java +++ b/src/main/java/com/back/domain/board/entity/Comment.java @@ -34,4 +34,24 @@ public class Comment extends BaseEntity { @OneToMany(mappedBy = "comment", cascade = CascadeType.ALL, orphanRemoval = true) private List commentLikes = new ArrayList<>(); + + // -------------------- 생성자 -------------------- + public Comment(Post post, User user, String content) { + this.post = post; + this.user = user; + this.content = content; + } + + public Comment(Post post, User user, String content, Comment parent) { + this.post = post; + this.user = user; + this.content = content; + this.parent = parent; + } + + // -------------------- 비즈니스 메서드 -------------------- + // 댓글 업데이트 + public void update(String content) { + this.content = content; + } } diff --git a/src/main/java/com/back/domain/board/entity/Post.java b/src/main/java/com/back/domain/board/entity/Post.java index 9f79ca63..a64297d5 100644 --- a/src/main/java/com/back/domain/board/entity/Post.java +++ b/src/main/java/com/back/domain/board/entity/Post.java @@ -32,4 +32,34 @@ public class Post extends BaseEntity { @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) private List comments = new ArrayList<>(); + + // -------------------- 생성자 -------------------- + public Post(User user, String title, String content) { + this.user = user; + this.title = title; + this.content = content; + } + + // -------------------- 비즈니스 메서드 -------------------- + // 게시글 업데이트 + public void update(String title, String content) { + this.title = title; + this.content = content; + } + + // 카테고리 업데이트 + public void updateCategories(List categories) { + this.postCategoryMappings.clear(); + categories.forEach(category -> + this.postCategoryMappings.add(new PostCategoryMapping(this, category)) + ); + } + + // -------------------- 헬퍼 메서드 -------------------- + // 게시글에 연결된 카테고리 목록 조회 + public List getCategories() { + return postCategoryMappings.stream() + .map(PostCategoryMapping::getCategory) + .toList(); + } } diff --git a/src/main/java/com/back/domain/board/entity/PostCategory.java b/src/main/java/com/back/domain/board/entity/PostCategory.java index a99cef4f..b47332df 100644 --- a/src/main/java/com/back/domain/board/entity/PostCategory.java +++ b/src/main/java/com/back/domain/board/entity/PostCategory.java @@ -7,6 +7,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; +import java.util.ArrayList; import java.util.List; @Entity @@ -17,4 +18,10 @@ public class PostCategory extends BaseEntity { @OneToMany(mappedBy = "category", cascade = CascadeType.ALL, orphanRemoval = true) private List postCategoryMappings; + + // -------------------- 생성자 -------------------- + public PostCategory(String name) { + this.name = name; + this.postCategoryMappings = new ArrayList<>(); + } } \ No newline at end of file diff --git a/src/main/java/com/back/domain/board/entity/PostCategoryMapping.java b/src/main/java/com/back/domain/board/entity/PostCategoryMapping.java index 6f1dce8d..5524e924 100644 --- a/src/main/java/com/back/domain/board/entity/PostCategoryMapping.java +++ b/src/main/java/com/back/domain/board/entity/PostCategoryMapping.java @@ -18,4 +18,10 @@ public class PostCategoryMapping { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "category_id") private PostCategory category; + + // -------------------- 생성자 -------------------- + public PostCategoryMapping(Post post, PostCategory category) { + this.post = post; + this.category = category; + } } diff --git a/src/main/java/com/back/domain/board/repository/CommentRepository.java b/src/main/java/com/back/domain/board/repository/CommentRepository.java new file mode 100644 index 00000000..25457552 --- /dev/null +++ b/src/main/java/com/back/domain/board/repository/CommentRepository.java @@ -0,0 +1,9 @@ +package com.back.domain.board.repository; + +import com.back.domain.board.entity.Comment; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface CommentRepository extends JpaRepository, CommentRepositoryCustom { +} diff --git a/src/main/java/com/back/domain/board/repository/CommentRepositoryCustom.java b/src/main/java/com/back/domain/board/repository/CommentRepositoryCustom.java new file mode 100644 index 00000000..2ab1193e --- /dev/null +++ b/src/main/java/com/back/domain/board/repository/CommentRepositoryCustom.java @@ -0,0 +1,9 @@ +package com.back.domain.board.repository; + +import com.back.domain.board.dto.CommentListResponse; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface CommentRepositoryCustom { + Page getCommentsByPostId(Long postId, Pageable pageable); +} diff --git a/src/main/java/com/back/domain/board/repository/CommentRepositoryImpl.java b/src/main/java/com/back/domain/board/repository/CommentRepositoryImpl.java new file mode 100644 index 00000000..ec7f5f47 --- /dev/null +++ b/src/main/java/com/back/domain/board/repository/CommentRepositoryImpl.java @@ -0,0 +1,158 @@ +package com.back.domain.board.repository; + +import com.back.domain.board.dto.CommentListResponse; +import com.back.domain.board.dto.QAuthorResponse; +import com.back.domain.board.dto.QCommentListResponse; +import com.back.domain.board.entity.Comment; +import com.back.domain.board.entity.QComment; +import com.back.domain.board.entity.QCommentLike; +import com.back.domain.user.entity.QUser; +import com.back.domain.user.entity.QUserProfile; +import com.querydsl.core.types.Order; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.core.types.dsl.PathBuilder; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +import java.util.*; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +public class CommentRepositoryImpl implements CommentRepositoryCustom { + private final JPAQueryFactory queryFactory; + + @Override + public Page getCommentsByPostId(Long postId, Pageable pageable) { + QComment comment = QComment.comment; + QCommentLike commentLike = QCommentLike.commentLike; + + // 정렬 조건 + List> orders = buildOrderSpecifiers(pageable, comment, commentLike); + + // 부모 댓글 조회 + List parents = fetchComments( + comment.post.id.eq(postId).and(comment.parent.isNull()), + orders, + pageable.getOffset(), + pageable.getPageSize() + ); + + if (parents.isEmpty()) { + return new PageImpl<>(parents, pageable, 0); + } + + // 부모 id 수집 + List parentIds = parents.stream() + .map(CommentListResponse::getCommentId) + .toList(); + + // 자식 댓글 조회 + List children = fetchComments( + comment.parent.id.in(parentIds), + List.of(comment.createdAt.asc()), + null, + null + ); + + // 부모 + 자식 id 합쳐서 likeCount 한 번에 조회 + List allIds = new ArrayList<>(parentIds); + allIds.addAll(children.stream().map(CommentListResponse::getCommentId).toList()); + + Map likeCountMap = queryFactory + .select(commentLike.comment.id, commentLike.count()) + .from(commentLike) + .where(commentLike.comment.id.in(allIds)) + .groupBy(commentLike.comment.id) + .fetch() + .stream() + .collect(Collectors.toMap( + tuple -> tuple.get(commentLike.comment.id), + tuple -> tuple.get(commentLike.count()) + )); + + // likeCount 세팅 + parents.forEach(p -> p.setLikeCount(likeCountMap.getOrDefault(p.getCommentId(), 0L))); + children.forEach(c -> c.setLikeCount(likeCountMap.getOrDefault(c.getCommentId(), 0L))); + + // parentId → children 매핑 + Map> childMap = children.stream() + .collect(Collectors.groupingBy(CommentListResponse::getParentId)); + + parents.forEach(p -> + p.setChildren(childMap.getOrDefault(p.getCommentId(), List.of())) + ); + + // 총 개수 (부모 댓글만 카운트) + Long total = queryFactory + .select(comment.count()) + .from(comment) + .where(comment.post.id.eq(postId).and(comment.parent.isNull())) + .fetchOne(); + + return new PageImpl<>(parents, pageable, total != null ? total : 0L); + } + + /** + * 공통 댓글 조회 메서드 (부모/자식 공통) + */ + private List fetchComments( + BooleanExpression condition, + List> orders, + Long offset, + Integer limit + ) { + QComment comment = QComment.comment; + QUser user = QUser.user; + QUserProfile profile = QUserProfile.userProfile; + + var query = queryFactory + .select(new QCommentListResponse( + comment.id, + comment.post.id, + comment.parent.id, + new QAuthorResponse(user.id, profile.nickname), + comment.content, + Expressions.constant(0L), // likeCount placeholder + comment.createdAt, + comment.updatedAt, + Expressions.constant(Collections.emptyList()) + )) + .from(comment) + .leftJoin(comment.user, user) + .leftJoin(user.userProfile, profile) + .where(condition) + .orderBy(orders.toArray(new OrderSpecifier[0])); + + if (offset != null && limit != null) { + query.offset(offset).limit(limit); + } + + return query.fetch(); + } + + /** + * 정렬 조건 처리 + */ + private List> buildOrderSpecifiers(Pageable pageable, QComment comment, QCommentLike commentLike) { + PathBuilder entityPath = new PathBuilder<>(Comment.class, comment.getMetadata()); + List> orders = new ArrayList<>(); + + for (Sort.Order order : pageable.getSort()) { + Order direction = order.isAscending() ? Order.ASC : Order.DESC; + String prop = order.getProperty(); + + switch (prop) { + case "likeCount" -> orders.add(new OrderSpecifier<>(direction, commentLike.id.countDistinct())); + default -> orders.add(new OrderSpecifier<>(direction, + entityPath.getComparable(prop, Comparable.class))); + } + } + return orders; + } +} diff --git a/src/main/java/com/back/domain/board/repository/PostCategoryRepository.java b/src/main/java/com/back/domain/board/repository/PostCategoryRepository.java new file mode 100644 index 00000000..70e8a935 --- /dev/null +++ b/src/main/java/com/back/domain/board/repository/PostCategoryRepository.java @@ -0,0 +1,9 @@ +package com.back.domain.board.repository; + +import com.back.domain.board.entity.PostCategory; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface PostCategoryRepository extends JpaRepository { +} diff --git a/src/main/java/com/back/domain/board/repository/PostRepository.java b/src/main/java/com/back/domain/board/repository/PostRepository.java new file mode 100644 index 00000000..32912081 --- /dev/null +++ b/src/main/java/com/back/domain/board/repository/PostRepository.java @@ -0,0 +1,9 @@ +package com.back.domain.board.repository; + +import com.back.domain.board.entity.Post; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface PostRepository extends JpaRepository, PostRepositoryCustom { +} diff --git a/src/main/java/com/back/domain/board/repository/PostRepositoryCustom.java b/src/main/java/com/back/domain/board/repository/PostRepositoryCustom.java new file mode 100644 index 00000000..c00c5ba4 --- /dev/null +++ b/src/main/java/com/back/domain/board/repository/PostRepositoryCustom.java @@ -0,0 +1,9 @@ +package com.back.domain.board.repository; + +import com.back.domain.board.dto.PostListResponse; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface PostRepositoryCustom { + Page searchPosts(String keyword, String searchType, Long categoryId, Pageable pageable); +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/board/repository/PostRepositoryImpl.java b/src/main/java/com/back/domain/board/repository/PostRepositoryImpl.java new file mode 100644 index 00000000..7e6d09f3 --- /dev/null +++ b/src/main/java/com/back/domain/board/repository/PostRepositoryImpl.java @@ -0,0 +1,222 @@ +package com.back.domain.board.repository; + +import com.back.domain.board.dto.PostListResponse; +import com.back.domain.board.dto.QPostListResponse; +import com.back.domain.board.dto.QPostListResponse_AuthorResponse; +import com.back.domain.board.dto.QPostListResponse_CategoryResponse; +import com.back.domain.board.entity.*; +import com.back.domain.user.entity.QUser; +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.Tuple; +import com.querydsl.core.types.Order; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.core.types.dsl.PathBuilder; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +import java.util.*; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +public class PostRepositoryImpl implements PostRepositoryCustom { + private final JPAQueryFactory queryFactory; + + /** + * 게시글 다건 검색 + * + * @param keyword 검색 키워드 + * @param searchType 검색 타입(title/content/author/전체) + * @param categoryId 카테고리 ID 필터 (nullable) + * @param pageable 페이징 + 정렬 조건 + */ + @Override + public Page searchPosts(String keyword, String searchType, Long categoryId, Pageable pageable) { + // 검색 조건 생성 + BooleanBuilder where = buildWhere(keyword, searchType, categoryId); + + // 정렬 조건 생성 + List> orders = buildOrderSpecifiers(pageable); + + // 메인 게시글 쿼리 실행 + List results = fetchPosts(where, orders, pageable); + + // 카테고리 조회 후 DTO에 주입 + injectCategories(results); + + // 전체 카운트 조회 + long total = countPosts(where, categoryId); + + // 결과를 Page로 감싸서 반환 + return new PageImpl<>(results, pageable, total); + } + + /** + * 검색 조건 생성 + * - keyword + searchType(title/content/author)에 따라 동적 조건 추가 + * - categoryId가 주어지면 카테고리 필터 조건 추가 + */ + private BooleanBuilder buildWhere(String keyword, String searchType, Long categoryId) { + QPost post = QPost.post; + QPostCategoryMapping categoryMapping = QPostCategoryMapping.postCategoryMapping; + + BooleanBuilder where = new BooleanBuilder(); + + // 검색 조건 추가 + if (keyword != null && !keyword.isBlank()) { + switch (searchType) { + case "title" -> where.and(post.title.containsIgnoreCase(keyword)); + case "content" -> where.and(post.content.containsIgnoreCase(keyword)); + case "author" -> where.and(post.user.username.containsIgnoreCase(keyword)); + default -> where.and( + post.title.containsIgnoreCase(keyword) + .or(post.content.containsIgnoreCase(keyword)) + .or(post.user.username.containsIgnoreCase(keyword)) + ); + } + } + + // 카테고리 필터링 + if (categoryId != null) { + where.and(categoryMapping.category.id.eq(categoryId)); + } + + return where; + } + + /** + * 정렬 처리 빌더 + * - Pageable의 Sort 정보 기반으로 OrderSpecifier 생성 + * - likeCount/bookmarkCount/commentCount -> countDistinct() 기준 정렬 + * - 그 외 속성 -> Post 엔티티 필드 기준 정렬 + */ + private List> buildOrderSpecifiers(Pageable pageable) { + QPost post = QPost.post; + QPostLike postLike = QPostLike.postLike; + QPostBookmark postBookmark = QPostBookmark.postBookmark; + QComment comment = QComment.comment; + + List> orders = new ArrayList<>(); + PathBuilder entityPath = new PathBuilder<>(Post.class, post.getMetadata()); + + // 정렬 조건 추가 + for (Sort.Order order : pageable.getSort()) { + Order direction = order.isAscending() ? Order.ASC : Order.DESC; + String prop = order.getProperty(); + switch (prop) { + case "likeCount" -> orders.add(new OrderSpecifier<>(direction, postLike.id.countDistinct())); + case "bookmarkCount" -> orders.add(new OrderSpecifier<>(direction, postBookmark.id.countDistinct())); + case "commentCount" -> orders.add(new OrderSpecifier<>(direction, comment.id.countDistinct())); + default -> + orders.add(new OrderSpecifier<>(direction, entityPath.getComparable(prop, Comparable.class))); + } + } + + return orders; + } + + /** + * 게시글 조회 (메인 쿼리) + * - Post + User join + * - 좋아요, 북마크, 댓글 countDistinct() 집계 + * - groupBy(post.id, user.id, userProfie.nickname) + * - Pageable offset/limit 적용 + */ + private List fetchPosts(BooleanBuilder where, List> orders, Pageable pageable) { + QPost post = QPost.post; + QUser user = QUser.user; + QPostLike postLike = QPostLike.postLike; + QPostBookmark postBookmark = QPostBookmark.postBookmark; + QComment comment = QComment.comment; + + return queryFactory + .select(new QPostListResponse( + post.id, + new QPostListResponse_AuthorResponse(user.id, user.userProfile.nickname), + post.title, + Expressions.constant(Collections.emptyList()), // 카테고리는 나중에 주입 + postLike.id.countDistinct(), + postBookmark.id.countDistinct(), + comment.id.countDistinct(), + post.createdAt, + post.updatedAt + )) + .from(post) + .leftJoin(post.user, user) + .leftJoin(post.postLikes, postLike) + .leftJoin(post.postBookmarks, postBookmark) + .leftJoin(post.comments, comment) + .where(where) + .groupBy(post.id, user.id, user.userProfile.nickname) + .orderBy(orders.toArray(new OrderSpecifier[0])) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + } + + /** + * 카테고리 일괄 조회 & 매핑 + * - postId 목록을 모아 IN 쿼리 실행 + * - 결과를 Map>로 변환 + * - 각 PostListResponse DTO에 categories 주입 + */ + private void injectCategories(List results) { + if (results.isEmpty()) return; + + QPostCategoryMapping categoryMapping = QPostCategoryMapping.postCategoryMapping; + + // postId 목록 생성 + List postIds = results.stream() + .map(PostListResponse::getPostId) + .toList(); + + // 해당하는 카테고리 정보 조회 + List categoryTuples = queryFactory + .select( + categoryMapping.post.id, + new QPostListResponse_CategoryResponse(categoryMapping.category.id, categoryMapping.category.name) + ) + .from(categoryMapping) + .where(categoryMapping.post.id.in(postIds)) + .fetch(); + + // Map>로 변환 + Map> categoryMap = categoryTuples.stream() + .collect(Collectors.groupingBy( + tuple -> Objects.requireNonNull(tuple.get(categoryMapping.post.id)), + Collectors.mapping(t -> t.get(1, PostListResponse.CategoryResponse.class), Collectors.toList()) + )); + + // categories 주입 + results.forEach(r -> r.setCategories(categoryMap.getOrDefault(r.getPostId(), List.of()))); + } + + /** + * 전체 게시글 개수 조회 + * - 조건에 맞는 게시글 총 개수를 가져옴 + * - categoryId 필터가 있으면 postCategoryMapping join 포함 + */ + private long countPosts(BooleanBuilder where, Long categoryId) { + QPost post = QPost.post; + QPostCategoryMapping categoryMapping = QPostCategoryMapping.postCategoryMapping; + + // 카운트 쿼리 + JPAQuery countQuery = queryFactory + .select(post.countDistinct()) + .from(post); + + // 카테고리 필터링 + if (categoryId != null) { + countQuery.leftJoin(post.postCategoryMappings, categoryMapping); + } + + Long total = countQuery.where(where).fetchOne(); + + return total != null ? total : 0L; + } +} diff --git a/src/main/java/com/back/domain/board/service/CommentService.java b/src/main/java/com/back/domain/board/service/CommentService.java new file mode 100644 index 00000000..e6010a4c --- /dev/null +++ b/src/main/java/com/back/domain/board/service/CommentService.java @@ -0,0 +1,123 @@ +package com.back.domain.board.service; + +import com.back.domain.board.dto.CommentListResponse; +import com.back.domain.board.dto.CommentRequest; +import com.back.domain.board.dto.CommentResponse; +import com.back.domain.board.dto.PageResponse; +import com.back.domain.board.entity.Comment; +import com.back.domain.board.entity.Post; +import com.back.domain.board.repository.CommentRepository; +import com.back.domain.board.repository.PostRepository; +import com.back.domain.user.entity.User; +import com.back.domain.user.repository.UserRepository; +import com.back.global.exception.CustomException; +import com.back.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class CommentService { + private final CommentRepository commentRepository; + private final UserRepository userRepository; + private final PostRepository postRepository; + + /** + * 댓글 생성 서비스 + * 1. User 조회 + * 2. Post 조회 + * 3. Comment 생성 + * 4. Comment 저장 및 CommentResponse 반환 + */ + public CommentResponse createComment(Long postId, CommentRequest request, Long userId) { + // User 조회 + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + // Post 조회 + Post post = postRepository.findById(postId) + .orElseThrow(() -> new CustomException(ErrorCode.POST_NOT_FOUND)); + + // Comment 생성 + Comment comment = new Comment(post, user, request.content()); + + // Comment 저장 및 응답 반환 + commentRepository.save(comment); + return CommentResponse.from(comment); + } + + /** + * 댓글 다건 조회 서비스 + * 1. Post 조회 + * 2. 해당 Post의 댓글 전체 조회 (대댓글 포함, 페이징) + * 3. PageResponse 반환 + */ + @Transactional(readOnly = true) + public PageResponse getComments(Long postId, Pageable pageable) { + // Post 조회 + Post post = postRepository.findById(postId) + .orElseThrow(() -> new CustomException(ErrorCode.POST_NOT_FOUND)); + + // 댓글 목록 조회 + Page comments = commentRepository.getCommentsByPostId(postId, pageable); + + return PageResponse.from(comments); + } + + /** + * 댓글 수정 서비스 + * 1. Post 조회 + * 2. Comment 조회 + * 3. 작성자 검증 + * 4. Comment 업데이트 (내용) + * 5. CommentResponse 반환 + */ + public CommentResponse updateComment(Long postId, Long commentId, CommentRequest request, Long userId) { + // Post 조회 + Post post = postRepository.findById(postId) + .orElseThrow(() -> new CustomException(ErrorCode.POST_NOT_FOUND)); + + // Comment 조회 + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new CustomException(ErrorCode.COMMENT_NOT_FOUND)); + + // 작성자 검증 + if (!comment.getUser().getId().equals(userId)) { + throw new CustomException(ErrorCode.COMMENT_NO_PERMISSION); + } + + // Comment 업데이트 + comment.update(request.content()); + + // 응답 반환 + return CommentResponse.from(comment); + } + + /** + * 댓글 삭제 서비스 + * 1. Post 조회 + * 2. Comment 조회 + * 3. 작성자 검증 + * 4. Comment 삭제 + */ + public void deleteComment(Long postId, Long commentId, Long userId) { + // Post 조회 + Post post = postRepository.findById(postId) + .orElseThrow(() -> new CustomException(ErrorCode.POST_NOT_FOUND)); + + // Comment 조회 + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new CustomException(ErrorCode.COMMENT_NOT_FOUND)); + + // 작성자 검증 + if (!comment.getUser().getId().equals(userId)) { + throw new CustomException(ErrorCode.COMMENT_NO_PERMISSION); + } + + commentRepository.delete(comment); + } +} diff --git a/src/main/java/com/back/domain/board/service/PostService.java b/src/main/java/com/back/domain/board/service/PostService.java new file mode 100644 index 00000000..79f0bbec --- /dev/null +++ b/src/main/java/com/back/domain/board/service/PostService.java @@ -0,0 +1,134 @@ +package com.back.domain.board.service; + +import com.back.domain.board.dto.*; +import com.back.domain.board.entity.Post; +import com.back.domain.board.entity.PostCategory; +import com.back.domain.board.repository.PostCategoryRepository; +import com.back.domain.board.repository.PostRepository; +import com.back.domain.user.entity.User; +import com.back.domain.user.repository.UserRepository; +import com.back.global.exception.CustomException; +import com.back.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional +public class PostService { + private final PostRepository postRepository; + private final UserRepository userRepository; + private final PostCategoryRepository postCategoryRepository; + + /** + * 게시글 생성 서비스 + * 1. User 조회 + * 2. Post 생성 + * 3. Category 매핑 + * 4. Post 저장 및 PostResponse 반환 + */ + public PostResponse createPost(PostRequest request, Long userId) { + + // User 조회 + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + // Post 생성 + Post post = new Post(user, request.title(), request.content()); + + // Category 매핑 + if (request.categoryIds() != null) { + List categories = postCategoryRepository.findAllById(request.categoryIds()); + if (categories.size() != request.categoryIds().size()) { + throw new CustomException(ErrorCode.CATEGORY_NOT_FOUND); + } + post.updateCategories(categories); + } + + // Post 저장 및 응답 반환 + Post saved = postRepository.save(post); + return PostResponse.from(saved); + } + + /** + * 게시글 다건 조회 서비스 + * 1. Post 검색 (키워드, 검색타입, 카테고리, 페이징) + * 2. PageResponse 반환 + */ + @Transactional(readOnly = true) + public PageResponse getPosts(String keyword, String searchType, Long categoryId, Pageable pageable) { + Page posts = postRepository.searchPosts(keyword, searchType, categoryId, pageable); + return PageResponse.from(posts); + } + + /** + * 게시글 단건 조회 서비스 + * 1. Post 조회 + * 2. PostResponse 반환 + */ + @Transactional(readOnly = true) + public PostDetailResponse getPost(Long postId) { + // Post 조회 + Post post = postRepository.findById(postId) + .orElseThrow(() -> new CustomException(ErrorCode.POST_NOT_FOUND)); + + // 응답 반환 + return PostDetailResponse.from(post); + } + + /** + * 게시글 수정 서비스 + * 1. Post 조회 + * 2. 작성자 검증 + * 3. Post 업데이트 (제목, 내용, 카테고리) + * 4. PostResponse 반환 + */ + public PostResponse updatePost(Long postId, PostRequest request, Long userId) { + // Post 조회 + Post post = postRepository.findById(postId) + .orElseThrow(() -> new CustomException(ErrorCode.POST_NOT_FOUND)); + + // 작성자 검증 + if (!post.getUser().getId().equals(userId)) { + throw new CustomException(ErrorCode.POST_NO_PERMISSION); + } + + // Post 업데이트 + post.update(request.title(), request.content()); + + // Category 매핑 업데이트 + List categories = postCategoryRepository.findAllById(request.categoryIds()); + if (categories.size() != request.categoryIds().size()) { + throw new CustomException(ErrorCode.CATEGORY_NOT_FOUND); + } + post.updateCategories(categories); + + // 응답 반환 + return PostResponse.from(post); + } + + /** + * 게시글 삭제 서비스 + * 1. Post 조회 + * 2. 작성자 검증 + * 3. Post 삭제 + */ + public void deletePost(Long postId, Long userId) { + // Post 조회 + Post post = postRepository.findById(postId) + .orElseThrow(() -> new CustomException(ErrorCode.POST_NOT_FOUND)); + + // 작성자 검증 + if (!post.getUser().getId().equals(userId)) { + throw new CustomException(ErrorCode.POST_NO_PERMISSION); + } + + // Post 삭제 + postRepository.delete(post); + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/chat/room/service/RoomChatService.java b/src/main/java/com/back/domain/chat/room/service/RoomChatService.java index 8999bf65..c938ddf5 100644 --- a/src/main/java/com/back/domain/chat/room/service/RoomChatService.java +++ b/src/main/java/com/back/domain/chat/room/service/RoomChatService.java @@ -27,7 +27,6 @@ @Service @RequiredArgsConstructor -@Transactional(readOnly = true) public class RoomChatService { private final RoomChatMessageRepository roomChatMessageRepository; @@ -60,6 +59,7 @@ public RoomChatMessage saveRoomChatMessage(RoomChatMessageDto roomChatMessageDto } // 방 채팅 기록 조회 + @Transactional(readOnly = true) public RoomChatPageResponse getRoomChatHistory(Long roomId, int page, int size, LocalDateTime before) { // 방 존재 여부 확인 @@ -125,6 +125,14 @@ public ChatClearedNotification.ClearedByDto clearRoomChat(Long roomId, Long user } } + // 방의 현재 채팅 메시지 수 조회 + @Transactional(readOnly = true) + public int getRoomChatCount(Long roomId) { + return roomChatMessageRepository.countByRoomId(roomId); + } + + // --------------------- 헬퍼 메서드들 --------------------- + // 채팅 관리 권한 확인 (방장 또는 부방장) private boolean canManageChat(RoomRole role) { return role == RoomRole.HOST || role == RoomRole.SUB_HOST; @@ -153,9 +161,4 @@ private RoomChatMessageDto convertToDto(RoomChatMessage message) { ); } - // 방의 현재 채팅 메시지 수 조회 - public int getRoomChatCount(Long roomId) { - return roomChatMessageRepository.countByRoomId(roomId); - } - } \ No newline at end of file diff --git a/src/main/java/com/back/domain/notification/controller/NotificationController.java b/src/main/java/com/back/domain/notification/controller/NotificationController.java new file mode 100644 index 00000000..903c5b0b --- /dev/null +++ b/src/main/java/com/back/domain/notification/controller/NotificationController.java @@ -0,0 +1,157 @@ +package com.back.domain.notification.controller; + +import com.back.domain.notification.dto.NotificationCreateRequest; +import com.back.domain.notification.dto.NotificationResponse; +import com.back.domain.notification.dto.NotificationListResponse; +import com.back.domain.notification.entity.Notification; +import com.back.domain.notification.service.NotificationService; +import com.back.domain.studyroom.entity.Room; +import com.back.domain.studyroom.repository.RoomRepository; +import com.back.domain.user.entity.User; +import com.back.domain.user.repository.UserRepository; +import com.back.global.common.dto.RsData; +import com.back.global.exception.CustomException; +import com.back.global.exception.ErrorCode; +import com.back.global.security.user.CustomUserDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/notifications") +@Tag(name = "알림", description = "알림 관련 API") +public class NotificationController { + + private final NotificationService notificationService; + private final UserRepository userRepository; + private final RoomRepository roomRepository; + + @Operation(summary = "알림 전송", description = "USER/ROOM/COMMUNITY/SYSTEM 타입별 알림 생성 및 전송") + @PostMapping + public ResponseEntity> createNotification( + @Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestBody NotificationCreateRequest request) { + + log.info("알림 전송 요청 - 타입: {}, 제목: {}", request.targetType(), request.title()); + + Notification notification = switch (request.targetType()) { + case "USER" -> { + User targetUser = userRepository.findById(request.targetId()) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + yield notificationService.createPersonalNotification( + targetUser, + request.title(), + request.message(), + request.redirectUrl() + ); + } + case "ROOM" -> { + Room room = roomRepository.findById(request.targetId()) + .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); + yield notificationService.createRoomNotification( + room, + request.title(), + request.message(), + request.redirectUrl() + ); + } + case "COMMUNITY" -> { + User targetUser = userRepository.findById(request.targetId()) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + yield notificationService.createCommunityNotification( + targetUser, + request.title(), + request.message(), + request.redirectUrl() + ); + } + case "SYSTEM" -> { + yield notificationService.createSystemNotification( + request.title(), + request.message(), + request.redirectUrl() + ); + } + default -> throw new IllegalArgumentException("유효하지 않은 알림 타입입니다: " + request.targetType()); + }; + + NotificationResponse response = NotificationResponse.from(notification); + + return ResponseEntity.ok(RsData.success("알림 전송 성공", response)); + } + + @Operation(summary = "알림 목록 조회", description = "사용자의 알림 목록 조회 (페이징)") + @GetMapping + public ResponseEntity> getNotifications( + @Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails, + @Parameter(description = "페이지 번호 (0부터 시작)") @RequestParam(defaultValue = "0") int page, + @Parameter(description = "페이지 크기") @RequestParam(defaultValue = "20") int size, + @Parameter(description = "읽지 않은 알림만 조회") @RequestParam(defaultValue = "false") boolean unreadOnly) { + + log.info("알림 목록 조회 - 유저 ID: {}, 읽지 않은 것만: {}", userDetails.getUserId(), unreadOnly); + + Pageable pageable = PageRequest.of(page, size); + Page notifications; + + if (unreadOnly) { + notifications = notificationService.getUnreadNotifications(userDetails.getUserId(), pageable); + } else { + notifications = notificationService.getUserNotifications(userDetails.getUserId(), pageable); + } + + long unreadCount = notificationService.getUnreadCount(userDetails.getUserId()); + + NotificationListResponse response = NotificationListResponse.from( + notifications, + userDetails.getUserId(), + unreadCount, + notificationService + ); + + return ResponseEntity.ok(RsData.success("알림 목록 조회 성공", response)); + } + + @Operation(summary = "알림 읽음 처리", description = "특정 알림을 읽음 상태로 변경") + @PutMapping("/{notificationId}/read") + public ResponseEntity> markAsRead( + @Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails, + @Parameter(description = "알림 ID") @PathVariable Long notificationId) { + + log.info("알림 읽음 처리 - 알림 ID: {}, 유저 ID: {}", notificationId, userDetails.getUserId()); + + User user = userRepository.findById(userDetails.getUserId()) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + notificationService.markAsRead(notificationId, user); + + Notification notification = notificationService.getNotification(notificationId); + NotificationResponse response = NotificationResponse.from(notification); + + return ResponseEntity.ok(RsData.success("알림 읽음 처리 성공", response)); + } + + @Operation(summary = "모든 알림 읽음 처리", description = "사용자의 읽지 않은 모든 알림을 읽음 상태로 변경") + @PutMapping("/read-all") + public ResponseEntity> markAllAsRead( + @Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails) { + + log.info("전체 알림 읽음 처리 - 유저 ID: {}", userDetails.getUserId()); + + User user = userRepository.findById(userDetails.getUserId()) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + notificationService.markMultipleAsRead(userDetails.getUserId(), user); + + return ResponseEntity.ok(RsData.success("전체 알림 읽음 처리 성공")); + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/notification/dto/NotificationCreateRequest.java b/src/main/java/com/back/domain/notification/dto/NotificationCreateRequest.java new file mode 100644 index 00000000..315b2668 --- /dev/null +++ b/src/main/java/com/back/domain/notification/dto/NotificationCreateRequest.java @@ -0,0 +1,13 @@ +package com.back.domain.notification.dto; + +public record NotificationCreateRequest( + String targetType, // USER, ROOM, SYSTEM + Long targetId, // nullable (SYSTEM일 때 null) + String title, + String message, + String notificationType, // STUDY_REMINDER, ROOM_JOIN 등 + String redirectUrl, // targetUrl + String scheduleType, // ONE_TIME (향후 확장용) + String scheduledAt // 예약 시간 (향후 확장용) +) { +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/notification/dto/NotificationItemDto.java b/src/main/java/com/back/domain/notification/dto/NotificationItemDto.java new file mode 100644 index 00000000..3137c4f1 --- /dev/null +++ b/src/main/java/com/back/domain/notification/dto/NotificationItemDto.java @@ -0,0 +1,31 @@ +package com.back.domain.notification.dto; + +import com.back.domain.notification.entity.Notification; +import com.back.domain.notification.entity.NotificationType; + +import java.time.LocalDateTime; + +/** + * 알림 목록 아이템 DTO + */ +public record NotificationItemDto( + Long notificationId, + String title, + String message, + NotificationType notificationType, + String targetUrl, + boolean isRead, + LocalDateTime createdAt +) { + public static NotificationItemDto from(Notification notification, boolean isRead) { + return new NotificationItemDto( + notification.getId(), + notification.getTitle(), + notification.getContent(), + notification.getType(), + notification.getTargetUrl(), + isRead, + notification.getCreatedAt() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/notification/dto/NotificationListResponse.java b/src/main/java/com/back/domain/notification/dto/NotificationListResponse.java new file mode 100644 index 00000000..3e7496dc --- /dev/null +++ b/src/main/java/com/back/domain/notification/dto/NotificationListResponse.java @@ -0,0 +1,48 @@ +package com.back.domain.notification.dto; + +import com.back.domain.notification.entity.Notification; +import com.back.domain.notification.service.NotificationService; +import org.springframework.data.domain.Page; + +import java.util.List; + +/** + * 알림 목록 응답 DTO + */ +public record NotificationListResponse( + List content, + PageableDto pageable, + long unreadCount +) { + + // 페이지 정보 DTO + public record PageableDto( + int page, + int size, + boolean hasNext + ) {} + + public static NotificationListResponse from( + Page notifications, + Long userId, + long unreadCount, + NotificationService notificationService) { + + List items = notifications.getContent().stream() + .map(notification -> { + boolean isRead = notificationService.isNotificationRead(notification.getId(), userId); + return NotificationItemDto.from(notification, isRead); + }) + .toList(); + + return new NotificationListResponse( + items, + new PageableDto( + notifications.getNumber(), + notifications.getSize(), + notifications.hasNext() + ), + unreadCount + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/notification/dto/NotificationResponse.java b/src/main/java/com/back/domain/notification/dto/NotificationResponse.java new file mode 100644 index 00000000..b0a56ab8 --- /dev/null +++ b/src/main/java/com/back/domain/notification/dto/NotificationResponse.java @@ -0,0 +1,46 @@ +package com.back.domain.notification.dto; + +import com.back.domain.notification.entity.Notification; +import com.back.domain.notification.entity.NotificationType; + +import java.time.LocalDateTime; + +/** + * 알림 응답 DTO + */ +public record NotificationResponse( + Long notificationId, + String title, + String message, + NotificationType notificationType, + String targetUrl, + boolean isRead, + LocalDateTime createdAt, + LocalDateTime readAt +) { + public static NotificationResponse from(Notification notification) { + return new NotificationResponse( + notification.getId(), + notification.getTitle(), + notification.getContent(), + notification.getType(), + notification.getTargetUrl(), + false, // 읽음 여부는 NotificationListResponse에서 처리 + notification.getCreatedAt(), + null // readAt은 NotificationRead에서 가져와야 함 + ); + } + + public static NotificationResponse from(Notification notification, boolean isRead, LocalDateTime readAt) { + return new NotificationResponse( + notification.getId(), + notification.getTitle(), + notification.getContent(), + notification.getType(), + notification.getTargetUrl(), + isRead, + notification.getCreatedAt(), + readAt + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/notification/dto/NotificationWebSocketDto.java b/src/main/java/com/back/domain/notification/dto/NotificationWebSocketDto.java new file mode 100644 index 00000000..232f246e --- /dev/null +++ b/src/main/java/com/back/domain/notification/dto/NotificationWebSocketDto.java @@ -0,0 +1,33 @@ +package com.back.domain.notification.dto; + +import com.back.domain.notification.entity.NotificationType; + +import java.time.LocalDateTime; + +public record NotificationWebSocketDto( + Long notificationId, + String title, + String message, + NotificationType notificationType, + String targetUrl, + LocalDateTime createdAt +) { + + public static NotificationWebSocketDto from( + Long notificationId, + String title, + String content, + NotificationType type, + String targetUrl, + LocalDateTime createdAt) { + + return new NotificationWebSocketDto( + notificationId, + title, + content, + type, + targetUrl, + createdAt + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/notification/entity/Notification.java b/src/main/java/com/back/domain/notification/entity/Notification.java new file mode 100644 index 00000000..47778c3c --- /dev/null +++ b/src/main/java/com/back/domain/notification/entity/Notification.java @@ -0,0 +1,105 @@ +package com.back.domain.notification.entity; + +import com.back.domain.studyroom.entity.Room; +import com.back.domain.user.entity.User; +import com.back.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@NoArgsConstructor +public class Notification extends BaseEntity { + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private NotificationType type; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "room_id") + private Room room; + + @Column(nullable = false) + private String title; + + @Column(columnDefinition = "TEXT", nullable = false) + private String content; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private NotificationStatus status; + + private String targetUrl; + + @OneToMany(mappedBy = "notification", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + private List notificationReads = new ArrayList<>(); + + private Notification(NotificationType type, String title, String content, String targetUrl) { + this.type = type; + this.title = title; + this.content = content; + this.targetUrl = targetUrl; + this.status = NotificationStatus.UNREAD; + } + + // 개인 알림 생성 + public static Notification createPersonalNotification(User user, String title, String content, String targetUrl) { + Notification notification = new Notification(NotificationType.PERSONAL, title, content, targetUrl); + notification.user = user; + return notification; + } + + // 스터디룸 알림 생성 + public static Notification createRoomNotification(Room room, String title, String content, String targetUrl) { + Notification notification = new Notification(NotificationType.ROOM, title, content, targetUrl); + notification.room = room; + return notification; + } + + // 시스템 알림 생성 + public static Notification createSystemNotification(String title, String content, String targetUrl) { + return new Notification(NotificationType.SYSTEM, title, content, targetUrl); + } + + // 커뮤니티 알림 생성 + public static Notification createCommunityNotification(User user, String title, String content, String targetUrl) { + Notification notification = new Notification(NotificationType.COMMUNITY, title, content, targetUrl); + notification.user = user; + return notification; + } + + // 알림을 읽음 상태로 변경 + public void markAsRead() { + this.status = NotificationStatus.READ; + } + + // 전체 알림인지 확인 + public boolean isSystemNotification() { + return this.type == NotificationType.SYSTEM; + } + + // 개인 알림인지 확인 + public boolean isPersonalNotification() { + return this.type == NotificationType.PERSONAL; + } + + // 특정 유저에게 표시되어야 하는 알림인지 확인 + public boolean isVisibleToUser(Long userId) { + + // 시스템 알림은 모두에게 표시 + if (isSystemNotification()) { + return true; + } + + // 개인 알림은 해당 유저에게만 표시 + return user != null && user.getId().equals(userId); + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/notification/entity/NotificationRead.java b/src/main/java/com/back/domain/notification/entity/NotificationRead.java new file mode 100644 index 00000000..34dc62f4 --- /dev/null +++ b/src/main/java/com/back/domain/notification/entity/NotificationRead.java @@ -0,0 +1,42 @@ +package com.back.domain.notification.entity; + +import com.back.domain.user.entity.User; +import com.back.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor +public class NotificationRead extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "notification_id", nullable = false) + private Notification notification; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(nullable = false) + private LocalDateTime readAt; + + public NotificationRead(Notification notification, User user) { + this.notification = notification; + this.user = user; + this.readAt = LocalDateTime.now(); + } + + // 읽음 기록 생성 + public static NotificationRead create(Notification notification, User user) { + return new NotificationRead(notification, user); + } + + // 특정 시간 이후에 읽었는지 확인 + public boolean isReadAfter(LocalDateTime dateTime) { + return readAt.isAfter(dateTime); + } +} diff --git a/src/main/java/com/back/domain/notification/entity/NotificationSetting.java b/src/main/java/com/back/domain/notification/entity/NotificationSetting.java new file mode 100644 index 00000000..f409af72 --- /dev/null +++ b/src/main/java/com/back/domain/notification/entity/NotificationSetting.java @@ -0,0 +1,59 @@ +package com.back.domain.notification.entity; + +import com.back.domain.user.entity.User; +import com.back.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +@Table( + uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "type"}) +) +public class NotificationSetting extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private NotificationSettingType type; + + @Column(nullable = false) + private boolean enabled; + + public NotificationSetting(User user, NotificationSettingType type, boolean enabled) { + this.user = user; + this.type = type; + this.enabled = enabled; + } + + // 알림 설정 생성 (기본 활성화 상태) + public static NotificationSetting create(User user, NotificationSettingType type) { + return new NotificationSetting(user, type, true); + } + + // 알림 설정 생성 (활성화 여부 직접 지정) + public static NotificationSetting create(User user, NotificationSettingType type, boolean enabled) { + return new NotificationSetting(user, type, enabled); + } + + // 알림 설정 토글 + public void toggle() { + this.enabled = !this.enabled; + } + + // 알림 활성화 + public void enable() { + this.enabled = true; + } + + // 알림 비활성화 + public void disable() { + this.enabled = false; + } + +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/notification/entity/NotificationSettingType.java b/src/main/java/com/back/domain/notification/entity/NotificationSettingType.java new file mode 100644 index 00000000..54376155 --- /dev/null +++ b/src/main/java/com/back/domain/notification/entity/NotificationSettingType.java @@ -0,0 +1,9 @@ +package com.back.domain.notification.entity; + +public enum NotificationSettingType { + SYSTEM, // 시스템 알림 + ROOM_JOIN, // 스터디룸 입장 알림 + ROOM_NOTICE, // 스터디룸 공지 알림 + POST_COMMENT, // 게시글 댓글 알림 + POST_LIKE // 게시글 좋아요 알림 +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/notification/entity/NotificationStatus.java b/src/main/java/com/back/domain/notification/entity/NotificationStatus.java new file mode 100644 index 00000000..d54b39ba --- /dev/null +++ b/src/main/java/com/back/domain/notification/entity/NotificationStatus.java @@ -0,0 +1,6 @@ +package com.back.domain.notification.entity; + +public enum NotificationStatus { + UNREAD, // 읽지 않음 + READ // 읽음 +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/notification/entity/NotificationType.java b/src/main/java/com/back/domain/notification/entity/NotificationType.java new file mode 100644 index 00000000..bba0734e --- /dev/null +++ b/src/main/java/com/back/domain/notification/entity/NotificationType.java @@ -0,0 +1,8 @@ +package com.back.domain.notification.entity; + +public enum NotificationType { + PERSONAL, // 개인 알림 + ROOM, // 스터디룸 관련 알림 + COMMUNITY, // 커뮤니티 관련 알림 + SYSTEM // 시스템 전체 알림 +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/notification/repository/NotificationReadRepository.java b/src/main/java/com/back/domain/notification/repository/NotificationReadRepository.java new file mode 100644 index 00000000..53a5cc8d --- /dev/null +++ b/src/main/java/com/back/domain/notification/repository/NotificationReadRepository.java @@ -0,0 +1,18 @@ +package com.back.domain.notification.repository; + +import com.back.domain.notification.entity.NotificationRead; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface NotificationReadRepository extends JpaRepository { + + // 특정 유저가 특정 알림을 읽었는지 확인 + boolean existsByNotificationIdAndUserId(Long notificationId, Long userId); + + // 특정 유저의 특정 알림 읽음 기록 조회 + Optional findByNotificationIdAndUserId(Long notificationId, Long userId); + + // 특정 알림의 모든 읽음 기록 삭제 + void deleteByNotificationId(Long notificationId); +} diff --git a/src/main/java/com/back/domain/notification/repository/NotificationRepository.java b/src/main/java/com/back/domain/notification/repository/NotificationRepository.java new file mode 100644 index 00000000..d9bbeabc --- /dev/null +++ b/src/main/java/com/back/domain/notification/repository/NotificationRepository.java @@ -0,0 +1,41 @@ +package com.back.domain.notification.repository; + +import com.back.domain.notification.entity.Notification; +import com.back.domain.notification.entity.NotificationType; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface NotificationRepository extends JpaRepository { + + // 특정 유저의 알림 목록 조회 (개인 알림 + 시스템 알림) + @Query("SELECT n FROM Notification n " + + "WHERE n.user.id = :userId OR n.type = 'SYSTEM' " + + "ORDER BY n.createdAt DESC") + Page findByUserIdOrSystemType(@Param("userId") Long userId, Pageable pageable); + + // 특정 유저의 읽지 않은 알림 개수 조회 + @Query("SELECT COUNT(n) FROM Notification n " + + "LEFT JOIN NotificationRead nr ON n.id = nr.notification.id AND nr.user.id = :userId " + + "WHERE (n.user.id = :userId OR n.type = 'SYSTEM') " + + "AND nr.id IS NULL") + long countUnreadByUserId(@Param("userId") Long userId); + + // 특정 유저의 읽지 않은 알림 목록 조회 + @Query("SELECT n FROM Notification n " + + "LEFT JOIN NotificationRead nr ON n.id = nr.notification.id AND nr.user.id = :userId " + + "WHERE (n.user.id = :userId OR n.type = 'SYSTEM') " + + "AND nr.id IS NULL " + + "ORDER BY n.createdAt DESC") + Page findUnreadByUserId(@Param("userId") Long userId, Pageable pageable); + + // 특정 스터디룸의 알림 조회 + Page findByRoomIdOrderByCreatedAtDesc(Long roomId, Pageable pageable); + + // 특정 타입의 알림 조회 + List findByType(NotificationType type); +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/notification/repository/NotificationSettingRepository.java b/src/main/java/com/back/domain/notification/repository/NotificationSettingRepository.java new file mode 100644 index 00000000..4acc5677 --- /dev/null +++ b/src/main/java/com/back/domain/notification/repository/NotificationSettingRepository.java @@ -0,0 +1,23 @@ +package com.back.domain.notification.repository; + +import com.back.domain.notification.entity.NotificationSetting; +import com.back.domain.notification.entity.NotificationSettingType; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface NotificationSettingRepository extends JpaRepository { + + // 특정 유저의 모든 알림 설정 조회 + List findByUserId(Long userId); + + // 특정 유저의 특정 타입 알림 설정 조회 + Optional findByUserIdAndType(Long userId, NotificationSettingType type); + + // 특정 유저의 특정 타입 알림 설정 존재 여부 + boolean existsByUserIdAndType(Long userId, NotificationSettingType type); + + // 특정 유저의 활성화된 알림 설정만 조회 + List findByUserIdAndEnabledTrue(Long userId); +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/notification/service/NotificationService.java b/src/main/java/com/back/domain/notification/service/NotificationService.java new file mode 100644 index 00000000..8647b8de --- /dev/null +++ b/src/main/java/com/back/domain/notification/service/NotificationService.java @@ -0,0 +1,184 @@ +package com.back.domain.notification.service; + +import com.back.domain.notification.dto.NotificationWebSocketDto; +import com.back.domain.notification.entity.Notification; +import com.back.domain.notification.entity.NotificationRead; +import com.back.domain.notification.repository.NotificationReadRepository; +import com.back.domain.notification.repository.NotificationRepository; +import com.back.domain.studyroom.entity.Room; +import com.back.domain.user.entity.User; +import com.back.global.exception.CustomException; +import com.back.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class NotificationService { + + private final NotificationRepository notificationRepository; + private final NotificationReadRepository notificationReadRepository; + private final NotificationWebSocketService webSocketService; + + // ==================== 알림 생성 및 전송 ==================== + + // 개인 알림 생성 및 전송 + @Transactional + public Notification createPersonalNotification(User user, String title, String content, String targetUrl) { + + // DB에 알림 저장 + Notification notification = Notification.createPersonalNotification(user, title, content, targetUrl); + notificationRepository.save(notification); + + // WebSocket으로 실시간 전송 + NotificationWebSocketDto dto = NotificationWebSocketDto.from( + notification.getId(), + notification.getTitle(), + notification.getContent(), + notification.getType(), + notification.getTargetUrl(), + notification.getCreatedAt() + ); + webSocketService.sendNotificationToUser(user.getId(), dto); + + log.info("개인 알림 생성 - 유저 ID: {}, 알림 ID: {}", user.getId(), notification.getId()); + return notification; + } + + // 스터디룸 알림 생성 및 전송 + @Transactional + public Notification createRoomNotification(Room room, String title, String content, String targetUrl) { + + Notification notification = Notification.createRoomNotification(room, title, content, targetUrl); + notificationRepository.save(notification); + + NotificationWebSocketDto dto = NotificationWebSocketDto.from( + notification.getId(), + notification.getTitle(), + notification.getContent(), + notification.getType(), + notification.getTargetUrl(), + notification.getCreatedAt() + ); + webSocketService.sendNotificationToRoom(room.getId(), dto); + + log.info("스터디룸 알림 생성 - 룸 ID: {}, 알림 ID: {}", room.getId(), notification.getId()); + return notification; + } + + // 시스템 전체 알림 생성 및 브로드캐스트 + @Transactional + public Notification createSystemNotification(String title, String content, String targetUrl) { + + Notification notification = Notification.createSystemNotification(title, content, targetUrl); + notificationRepository.save(notification); + + NotificationWebSocketDto dto = NotificationWebSocketDto.from( + notification.getId(), + notification.getTitle(), + notification.getContent(), + notification.getType(), + notification.getTargetUrl(), + notification.getCreatedAt() + ); + webSocketService.broadcastSystemNotification(dto); + + log.info("시스템 알림 생성 - 알림 ID: {}", notification.getId()); + return notification; + } + + // 커뮤니티 알림 생성 및 전송 + @Transactional + public Notification createCommunityNotification(User user, String title, String content, String targetUrl) { + + Notification notification = Notification.createCommunityNotification(user, title, content, targetUrl); + notificationRepository.save(notification); + + NotificationWebSocketDto dto = NotificationWebSocketDto.from( + notification.getId(), + notification.getTitle(), + notification.getContent(), + notification.getType(), + notification.getTargetUrl(), + notification.getCreatedAt() + ); + webSocketService.sendNotificationToUser(user.getId(), dto); + + log.info("커뮤니티 알림 생성 - 유저 ID: {}, 알림 ID: {}", user.getId(), notification.getId()); + return notification; + } + + // ==================== 알림 조회 ==================== + + // 특정 유저의 알림 목록 조회 (개인 알림 + 시스템 알림) + public Page getUserNotifications(Long userId, Pageable pageable) { + return notificationRepository.findByUserIdOrSystemType(userId, pageable); + } + + // 특정 유저의 읽지 않은 알림 목록 조회 + public Page getUnreadNotifications(Long userId, Pageable pageable) { + return notificationRepository.findUnreadByUserId(userId, pageable); + } + + // 특정 유저의 읽지 않은 알림 개수 조회 + public long getUnreadCount(Long userId) { + return notificationRepository.countUnreadByUserId(userId); + } + + // 알림 단건 조회 + public Notification getNotification(Long notificationId) { + return notificationRepository.findById(notificationId) + .orElseThrow(() -> new CustomException(ErrorCode.NOTIFICATION_NOT_FOUND)); + } + + // 특정 유저가 특정 알림을 읽었는지 확인 + public boolean isNotificationRead(Long notificationId, Long userId) { + return notificationReadRepository.existsByNotificationIdAndUserId(notificationId, userId); + } + + // ==================== 알림 읽음 처리 ==================== + + // 알림 읽음 처리 + @Transactional + public void markAsRead(Long notificationId, User user) { + // 1. 알림 존재 확인 + Notification notification = getNotification(notificationId); + + // 2. 이미 읽은 알림인지 확인 + if (notificationReadRepository.existsByNotificationIdAndUserId(notificationId, user.getId())) { + log.debug("이미 읽은 알림 - 알림 ID: {}, 유저 ID: {}", notificationId, user.getId()); + return; + } + + // 3. 읽음 기록 생성 + NotificationRead notificationRead = NotificationRead.create(notification, user); + notificationReadRepository.save(notificationRead); + + // 4. 알림 상태 업데이트 (선택적) + notification.markAsRead(); + + log.info("알림 읽음 처리 - 알림 ID: {}, 유저 ID: {}", notificationId, user.getId()); + } + + // 여러 알림 일괄 읽음 처리 + @Transactional + public void markMultipleAsRead(Long userId, User user) { + Page unreadNotifications = getUnreadNotifications(userId, Pageable.unpaged()); + + for (Notification notification : unreadNotifications) { + if (!notificationReadRepository.existsByNotificationIdAndUserId(notification.getId(), user.getId())) { + NotificationRead notificationRead = NotificationRead.create(notification, user); + notificationReadRepository.save(notificationRead); + notification.markAsRead(); + } + } + + log.info("일괄 읽음 처리 - 유저 ID: {}, 처리 개수: {}", userId, unreadNotifications.getTotalElements()); + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/notification/service/NotificationWebSocketService.java b/src/main/java/com/back/domain/notification/service/NotificationWebSocketService.java new file mode 100644 index 00000000..c7da31c0 --- /dev/null +++ b/src/main/java/com/back/domain/notification/service/NotificationWebSocketService.java @@ -0,0 +1,57 @@ +package com.back.domain.notification.service; + +import com.back.domain.notification.dto.NotificationWebSocketDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class NotificationWebSocketService { // WebSocket을 통한 실시간 알림 전송 서비스 + + private final SimpMessagingTemplate messagingTemplate; + + // 특정 유저에게 알림 전송 + public void sendNotificationToUser(Long userId, NotificationWebSocketDto notificationDto) { + try { + String destination = "/topic/user/" + userId + "/notifications"; + messagingTemplate.convertAndSend(destination, notificationDto); + + log.info("실시간 알림 전송 성공 - 유저 ID: {}, 알림 ID: {}, 제목: {}", + userId, notificationDto.notificationId(), notificationDto.title()); + + } catch (Exception e) { + log.error("실시간 알림 전송 실패 - 유저 ID: {}, 오류: {}", userId, e.getMessage(), e); + } + } + + // 전체 유저에게 시스템 알림 브로드캐스트 + public void broadcastSystemNotification(NotificationWebSocketDto notificationDto) { + try { + String destination = "/topic/notifications/system"; + messagingTemplate.convertAndSend(destination, notificationDto); + + log.info("시스템 알림 브로드캐스트 성공 - 알림 ID: {}, 제목: {}", + notificationDto.notificationId(), notificationDto.title()); + + } catch (Exception e) { + log.error("시스템 알림 브로드캐스트 실패 - 오류: {}", e.getMessage(), e); + } + } + + // 스터디룸 멤버들에게 알림 전송 + public void sendNotificationToRoom(Long roomId, NotificationWebSocketDto notificationDto) { + try { + String destination = "/topic/room/" + roomId + "/notifications"; + messagingTemplate.convertAndSend(destination, notificationDto); + + log.info("스터디룸 알림 전송 성공 - 룸 ID: {}, 알림 ID: {}, 제목: {}", + roomId, notificationDto.notificationId(), notificationDto.title()); + + } catch (Exception e) { + log.error("스터디룸 알림 전송 실패 - 룸 ID: {}, 오류: {}", roomId, e.getMessage(), e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/study/plan/controller/StudyPlanController.java b/src/main/java/com/back/domain/study/plan/controller/StudyPlanController.java index a1d7e295..6f163c04 100644 --- a/src/main/java/com/back/domain/study/plan/controller/StudyPlanController.java +++ b/src/main/java/com/back/domain/study/plan/controller/StudyPlanController.java @@ -1,5 +1,6 @@ package com.back.domain.study.plan.controller; +import com.back.domain.study.plan.dto.StudyPlanDeleteResponse; import com.back.domain.study.plan.dto.StudyPlanRequest; import com.back.domain.study.plan.dto.StudyPlanListResponse; import com.back.domain.study.plan.dto.StudyPlanResponse; @@ -111,15 +112,15 @@ public ResponseEntity> updateStudyPlan( description = "기존 학습 계획을 삭제합니다. 반복 계획의 경우 적용 범위를 applyScope로 설정 할 수 있으며" + "클라이언트에서는 paln에 repeat_rule이 있으면 반복 계획으로 간주하고 반드시 apply_scope를 쿼리 파라미터로 넘겨야 합니다." + "repeat_rule이 없으면 단발성 계획으로 간주하여 삭제 범위를 설정 할 필요가 없으므로 apply_scope를 넘기지 않아도 됩니다.") - public ResponseEntity> deleteStudyPlan( + public ResponseEntity> deleteStudyPlan( @AuthenticationPrincipal CustomUserDetails user, @PathVariable Long planId, @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate selectedDate, @RequestParam(name = "applyScope", required = true) ApplyScope applyScope) { Long userId = user.getUserId(); - studyPlanService.deleteStudyPlan(userId, planId, selectedDate, applyScope); - return ResponseEntity.ok(RsData.success("학습 계획이 성공적으로 삭제되었습니다.")); + StudyPlanDeleteResponse response = studyPlanService.deleteStudyPlan(userId, planId, selectedDate, applyScope); + return ResponseEntity.ok(RsData.success("학습 계획이 성공적으로 삭제되었습니다.",response)); } } diff --git a/src/main/java/com/back/domain/study/plan/dto/StudyPlanDeleteRequest.java b/src/main/java/com/back/domain/study/plan/dto/StudyPlanDeleteRequest.java deleted file mode 100644 index 6b9b8624..00000000 --- a/src/main/java/com/back/domain/study/plan/dto/StudyPlanDeleteRequest.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.back.domain.study.plan.dto; - -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; - -@Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor -public class StudyPlanDeleteRequest { - private DeleteScope deleteScope; - - public enum DeleteScope { - THIS_ONLY, // 이 날짜만 - FROM_THIS_DATE // 이 날짜부터 이후 모든 날짜 - } -} \ No newline at end of file diff --git a/src/main/java/com/back/domain/study/plan/dto/StudyPlanDeleteResponse.java b/src/main/java/com/back/domain/study/plan/dto/StudyPlanDeleteResponse.java new file mode 100644 index 00000000..c4451c13 --- /dev/null +++ b/src/main/java/com/back/domain/study/plan/dto/StudyPlanDeleteResponse.java @@ -0,0 +1,33 @@ +package com.back.domain.study.plan.dto; + +import com.back.domain.study.plan.entity.ApplyScope; +import com.back.domain.study.plan.entity.Color; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class StudyPlanDeleteResponse { + private Long id; + private String subject; + private LocalDateTime startDate; + private LocalDateTime endDate; + private Color color; + private LocalDate deletedDate; // 삭제된 날짜 + private ApplyScope applyScope; // 삭제 범위 + + public StudyPlanDeleteResponse(StudyPlanResponse plan, ApplyScope applyScope) { + this.id = plan.getId(); + this.subject = plan.getSubject(); + this.startDate = plan.getStartDate(); + this.endDate = plan.getEndDate(); + this.color = plan.getColor(); + this.deletedDate = plan.getStartDate().toLocalDate(); + this.applyScope = applyScope; + } +} diff --git a/src/main/java/com/back/domain/study/plan/dto/StudyPlanRequest.java b/src/main/java/com/back/domain/study/plan/dto/StudyPlanRequest.java index 6a1c7d42..c7e309ee 100644 --- a/src/main/java/com/back/domain/study/plan/dto/StudyPlanRequest.java +++ b/src/main/java/com/back/domain/study/plan/dto/StudyPlanRequest.java @@ -1,6 +1,7 @@ package com.back.domain.study.plan.dto; import com.back.domain.study.plan.entity.Color; +import com.back.domain.study.plan.entity.DayOfWeek; import com.back.domain.study.plan.entity.Frequency; import com.fasterxml.jackson.annotation.JsonFormat; import lombok.AllArgsConstructor; @@ -9,26 +10,41 @@ import lombok.Setter; import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.List; @Getter @Setter @NoArgsConstructor -@AllArgsConstructor public class StudyPlanRequest { private String subject; - @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss") private LocalDateTime startDate; - @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss") private LocalDateTime endDate; private Color color; // RepeatRule 중첩 객체 private RepeatRuleRequest repeatRule; + // LocalDateTime을 분 단위로 자르기 위한 setter + public void setStartDate(LocalDateTime startDate) { + this.startDate = startDate != null ? startDate.truncatedTo(ChronoUnit.MINUTES) : null; + } + public void setEndDate(LocalDateTime endDate) { + this.endDate = endDate != null ? endDate.truncatedTo(ChronoUnit.MINUTES) : null; + } + + // 분 단위로 자른 값을 생성자에서도 설정 + public StudyPlanRequest(String subject, LocalDateTime startDate, LocalDateTime endDate, + Color color, RepeatRuleRequest repeatRule) { + this.subject = subject; + this.startDate = startDate != null ? startDate.truncatedTo(ChronoUnit.MINUTES) : null; + this.endDate = endDate != null ? endDate.truncatedTo(ChronoUnit.MINUTES) : null; + this.color = color; + this.repeatRule = repeatRule; + } @Getter @Setter @@ -37,7 +53,7 @@ public class StudyPlanRequest { public static class RepeatRuleRequest { private Frequency frequency; private Integer intervalValue; - private List byDay = new ArrayList<>(); // 문자열 리스트 + private List byDay = new ArrayList<>(); @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd") private String untilDate; // "2025-12-31" 형태 diff --git a/src/main/java/com/back/domain/study/plan/dto/StudyPlanResponse.java b/src/main/java/com/back/domain/study/plan/dto/StudyPlanResponse.java index 5e1dda0a..b97535b7 100644 --- a/src/main/java/com/back/domain/study/plan/dto/StudyPlanResponse.java +++ b/src/main/java/com/back/domain/study/plan/dto/StudyPlanResponse.java @@ -42,36 +42,26 @@ public class StudyPlanResponse { @AllArgsConstructor public static class RepeatRuleResponse { private Frequency frequency; - private Integer repeatInterval; - // byDay 필드는 이미 List으로 선언되어 있음. - private List byDay = new ArrayList<>(); // "MON" 형태의 문자열 리스트 + private Integer intervalValue; + private List byDay = new ArrayList<>(); // "MON" 형태의 enum 문자열 리스트 @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd") private LocalDate untilDate; - // 엔티티 생성자: 그대로 유지 - + // RepeatRule 엔티티를 DTO로 변환하는 생성자 + // intervalValue의 경우 요청, 응답(프론트)에서는 intervalValue 사용 + // 백엔드 내에서는 repeatInterval 사용 public RepeatRuleResponse(com.back.domain.study.plan.entity.RepeatRule repeatRule) { if (repeatRule != null) { this.frequency = repeatRule.getFrequency(); - this.repeatInterval = repeatRule.getRepeatInterval(); + this.intervalValue = repeatRule.getRepeatInterval(); this.byDay = repeatRule.getByDay(); this.untilDate = repeatRule.getUntilDate(); } } - public List getByDaysList() { - if (byDay == null || byDay.isEmpty()) { - return List.of(); - } - - // List의 각 요소를 DayOfWeek enum으로 변환하여 반환 - return byDay.stream() - .map(com.back.domain.study.plan.entity.DayOfWeek::valueOf) - .collect(Collectors.toList()); - } } - //엔티티를 DTO로 변환하는 생성자 + // 엔티티를 DTO로 변환하는 생성자 public StudyPlanResponse(StudyPlan studyPlan) { if (studyPlan != null) { this.id = studyPlan.getId(); diff --git a/src/main/java/com/back/domain/study/plan/entity/RepeatRule.java b/src/main/java/com/back/domain/study/plan/entity/RepeatRule.java index 7b758a77..afe60418 100644 --- a/src/main/java/com/back/domain/study/plan/entity/RepeatRule.java +++ b/src/main/java/com/back/domain/study/plan/entity/RepeatRule.java @@ -29,7 +29,7 @@ public class RepeatRule extends BaseEntity { //요일은 응답에 들어있는 요일을 그대로 저장 (예: "WED") @Column(name = "by_day") - private List byDay = new ArrayList<>(); + private List byDay = new ArrayList<>(); private LocalDate untilDate; } diff --git a/src/main/java/com/back/domain/study/plan/entity/RepeatRuleEmbeddable.java b/src/main/java/com/back/domain/study/plan/entity/RepeatRuleEmbeddable.java index 2e5552d3..e196dd51 100644 --- a/src/main/java/com/back/domain/study/plan/entity/RepeatRuleEmbeddable.java +++ b/src/main/java/com/back/domain/study/plan/entity/RepeatRuleEmbeddable.java @@ -21,7 +21,7 @@ public class RepeatRuleEmbeddable { @Enumerated(EnumType.STRING) private Frequency frequency; - private Integer intervalValue; - private List byDay = new ArrayList<>(); + private Integer repeatInterval; + private List byDay = new ArrayList<>(); private LocalDate untilDate; // LocalDateTime → LocalDate 변경 } \ No newline at end of file diff --git a/src/main/java/com/back/domain/study/plan/entity/RepeatType.java b/src/main/java/com/back/domain/study/plan/entity/RepeatType.java deleted file mode 100644 index ff7238ba..00000000 --- a/src/main/java/com/back/domain/study/plan/entity/RepeatType.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.back.domain.study.plan.entity; - -//삭제 예정 -public enum RepeatType { - NONE, DAILY, WEEKLY, MONTHLY -} diff --git a/src/main/java/com/back/domain/study/plan/entity/StudyPlanException.java b/src/main/java/com/back/domain/study/plan/entity/StudyPlanException.java index e274ee1a..d9fcc762 100644 --- a/src/main/java/com/back/domain/study/plan/entity/StudyPlanException.java +++ b/src/main/java/com/back/domain/study/plan/entity/StudyPlanException.java @@ -61,7 +61,7 @@ public enum ExceptionType { @Column(name = "modified_repeat_rule") @AttributeOverrides({ @AttributeOverride(name = "frequency", column = @Column(name = "modified_frequency")), - @AttributeOverride(name = "intervalValue", column = @Column(name = "modified_repeat_interval")), + @AttributeOverride(name = "repeatInterval", column = @Column(name = "modified_interval_value")), @AttributeOverride(name = "byDay", column = @Column(name = "modified_by_day")), @AttributeOverride(name = "untilDate", column = @Column(name = "modified_until_date")) }) diff --git a/src/main/java/com/back/domain/study/plan/service/StudyPlanService.java b/src/main/java/com/back/domain/study/plan/service/StudyPlanService.java index 3493c4d4..10efea91 100644 --- a/src/main/java/com/back/domain/study/plan/service/StudyPlanService.java +++ b/src/main/java/com/back/domain/study/plan/service/StudyPlanService.java @@ -1,6 +1,6 @@ package com.back.domain.study.plan.service; -import com.back.domain.study.plan.dto.StudyPlanDeleteRequest; +import com.back.domain.study.plan.dto.StudyPlanDeleteResponse; import com.back.domain.study.plan.dto.StudyPlanRequest; import com.back.domain.study.plan.dto.StudyPlanResponse; import com.back.domain.study.plan.entity.*; @@ -69,19 +69,9 @@ private RepeatRule createRepeatRule(StudyPlanRequest.RepeatRuleRequest request, repeatRule.setStudyPlan(studyPlan); repeatRule.setFrequency(request.getFrequency()); repeatRule.setRepeatInterval(request.getIntervalValue() != null ? request.getIntervalValue() : 1); + // byDay 설정 (WEEKLY인 경우에만 의미 있음) + getByDayInWeekly(request, studyPlan, repeatRule); - // byDay 설정 (WEEKLY 인 경우에만) - if (request.getFrequency() == Frequency.WEEKLY) { - // 1. byDay가 없으면 시작일 요일을 자동으로 설정 (현재 구현 의도 반영) - if(request.getByDay() == null || request.getByDay().isEmpty()) { - String startDayOfWeek = studyPlan.getStartDate().getDayOfWeek().name().substring(0, 3); - // *가정: RepeatRule.byDay는 List 타입으로 가정 - repeatRule.setByDay(List.of(startDayOfWeek)); - } else { - // 2. byDay가 있다면 요청 값을 사용 (List to List 매핑 확인) - repeatRule.setByDay(request.getByDay()); - } - } // untilDate 설정 및 검증 LocalDate untilDate; @@ -207,7 +197,9 @@ private boolean shouldRepeatOnDate(StudyPlan originalPlan, LocalDate targetDate) case WEEKLY: if (repeatRule.getByDay() != null && !repeatRule.getByDay().isEmpty()) { - String targetDayOfWeek = targetDate.getDayOfWeek().name().substring(0, 3); + // string으로 요일을 뽑아낸 뒤 enum으로 변환. + // 비교해서 포함되지 않으면 false + DayOfWeek targetDayOfWeek = DayOfWeek.valueOf(targetDate.getDayOfWeek().name().substring(0, 3)); if (!repeatRule.getByDay().contains(targetDayOfWeek)) { return false; } @@ -269,7 +261,7 @@ private StudyPlanResponse createModifiedVirtualPlan(StudyPlan originalPlan, Stud RepeatRuleEmbeddable modifiedRule = exception.getModifiedRepeatRule(); StudyPlanResponse.RepeatRuleResponse newRepeatRule = new StudyPlanResponse.RepeatRuleResponse(); newRepeatRule.setFrequency(modifiedRule.getFrequency()); - newRepeatRule.setRepeatInterval(modifiedRule.getIntervalValue()); + newRepeatRule.setIntervalValue(modifiedRule.getRepeatInterval()); newRepeatRule.setByDay(modifiedRule.getByDay()); newRepeatRule.setUntilDate(modifiedRule.getUntilDate()); @@ -335,6 +327,10 @@ public StudyPlanResponse updateStudyPlan(Long userId, Long planId, StudyPlanRequ switch (updateType) { case ORIGINAL_PLAN_UPDATE: + // 요청에 반복 규칙이 있으면 반복 규칙 수정 후 원본 계획 수정 + if (request.getRepeatRule() != null) { + updateRepeatRule(originalPlan.getRepeatRule(), request.getRepeatRule(), originalPlan); + } return updateOriginalPlan(originalPlan, request); case REPEAT_INSTANCE_CREATE: @@ -390,11 +386,6 @@ private StudyPlanResponse updateOriginalPlan(StudyPlan originalPlan, StudyPlanRe if (request.getEndDate() != null) originalPlan.setEndDate(request.getEndDate()); if (request.getColor() != null) originalPlan.setColor(request.getColor()); - // 요청에 반복 규칙이 있고 원본 반복성 계획인 경우에만 반복 규칙 수정 - if (request.getRepeatRule() != null && originalPlan.getRepeatRule() != null) { - updateRepeatRule(originalPlan.getRepeatRule(), request.getRepeatRule()); - } - StudyPlan savedPlan = studyPlanRepository.save(originalPlan); return new StudyPlanResponse(savedPlan); } @@ -412,7 +403,7 @@ private StudyPlanResponse createRepeatException(StudyPlan originalPlan, StudyPla exception.setStudyPlan(originalPlan); exception.setExceptionDate(exceptionDate); exception.setExceptionType(StudyPlanException.ExceptionType.MODIFIED); - exception.setApplyScope(applyScope); // 파라미터로 받은 applyScope + exception.setApplyScope(applyScope); // 수정된 내용 설정 if (request.getSubject() != null) exception.setModifiedSubject(request.getSubject()); @@ -422,24 +413,10 @@ private StudyPlanResponse createRepeatException(StudyPlan originalPlan, StudyPla // 반복 규칙 수정. 요청에 반복 규칙이 있으면 설정 if (request.getRepeatRule() != null) { - RepeatRuleEmbeddable embeddable = new RepeatRuleEmbeddable(); - embeddable.setFrequency(request.getRepeatRule().getFrequency()); - embeddable.setIntervalValue(request.getRepeatRule().getIntervalValue()); - embeddable.setByDay(request.getRepeatRule().getByDay()); - - if (request.getRepeatRule().getUntilDate() != null && !request.getRepeatRule().getUntilDate().isEmpty()) { - try { - LocalDate untilDate = LocalDate.parse(request.getRepeatRule().getUntilDate()); - embeddable.setUntilDate(untilDate); - } catch (Exception e) { - throw new CustomException(ErrorCode.INVALID_DATE_FORMAT); - } - } - + RepeatRuleEmbeddable embeddable = createRepeatRuleEmbeddable(request.getRepeatRule(), request.getStartDate()); exception.setModifiedRepeatRule(embeddable); } - studyPlanExceptionRepository.save(exception); return createVirtualPlanForDate(originalPlan, exceptionDate); } @@ -458,25 +435,12 @@ private StudyPlanResponse updateExistingException(StudyPlan originalPlan, StudyP if (request.getEndDate() != null) existingException.setModifiedEndDate(request.getEndDate()); if (request.getColor() != null) existingException.setModifiedColor(request.getColor()); - // ApplyScope도 업데이트 (사용자가 범위를 변경할 수 있음) + // ApplyScope도 업데이트 existingException.setApplyScope(applyScope); // 반복 규칙 수정사항 있으면 예외 안에 추가 (embeddable) if (request.getRepeatRule() != null) { - RepeatRuleEmbeddable embeddable = new RepeatRuleEmbeddable(); - embeddable.setFrequency(request.getRepeatRule().getFrequency()); - embeddable.setIntervalValue(request.getRepeatRule().getIntervalValue()); - embeddable.setByDay(request.getRepeatRule().getByDay()); - - if (request.getRepeatRule().getUntilDate() != null && !request.getRepeatRule().getUntilDate().isEmpty()) { - try { - LocalDate untilDate = LocalDate.parse(request.getRepeatRule().getUntilDate()); - embeddable.setUntilDate(untilDate); - } catch (Exception e) { - throw new CustomException(ErrorCode.INVALID_DATE_FORMAT); - } - } - + RepeatRuleEmbeddable embeddable = createRepeatRuleEmbeddable(request.getRepeatRule(), request.getStartDate()); existingException.setModifiedRepeatRule(embeddable); } @@ -484,12 +448,13 @@ private StudyPlanResponse updateExistingException(StudyPlan originalPlan, StudyP return createVirtualPlanForDate(originalPlan, exceptionDate); } - // 원본의 반복 룰 수정 (엔티티) - private void updateRepeatRule(RepeatRule repeatRule, StudyPlanRequest.RepeatRuleRequest request) { + private void updateRepeatRule(RepeatRule repeatRule, StudyPlanRequest.RepeatRuleRequest request, StudyPlan studyPlan) { if (request.getFrequency() != null) repeatRule.setFrequency(request.getFrequency()); if (request.getIntervalValue() != null) repeatRule.setRepeatInterval(request.getIntervalValue()); - if (request.getByDay() != null) repeatRule.setByDay(request.getByDay()); + + // byDay 자동 설정 (기존 메서드 재사용) + getByDayInWeekly(request, studyPlan, repeatRule); if (request.getUntilDate() != null && !request.getUntilDate().isEmpty()) { try { @@ -501,22 +466,52 @@ private void updateRepeatRule(RepeatRule repeatRule, StudyPlanRequest.RepeatRule } } + // RepeatRuleEmbeddable 생성 헬퍼 메서드 (중복 코드 제거) + private RepeatRuleEmbeddable createRepeatRuleEmbeddable(StudyPlanRequest.RepeatRuleRequest request, LocalDateTime startDate) { + RepeatRuleEmbeddable embeddable = new RepeatRuleEmbeddable(); + embeddable.setFrequency(request.getFrequency()); + embeddable.setRepeatInterval(request.getIntervalValue()); + + // byDay 자동 설정 (오버로딩된 메서드 사용) + getByDayInWeekly(request, startDate, embeddable); + + if (request.getUntilDate() != null && !request.getUntilDate().isEmpty()) { + try { + LocalDate untilDate = LocalDate.parse(request.getUntilDate()); + embeddable.setUntilDate(untilDate); + } catch (Exception e) { + throw new CustomException(ErrorCode.INVALID_DATE_FORMAT); + } + } + + return embeddable; + } + // ==================== 삭제 =================== @Transactional - public void deleteStudyPlan(Long userId, Long planId, LocalDate selectedDate, ApplyScope applyScope) { + public StudyPlanDeleteResponse deleteStudyPlan(Long userId, Long planId, LocalDate selectedDate, ApplyScope applyScope) { StudyPlan studyPlan = studyPlanRepository.findById(planId) .orElseThrow(() -> new CustomException(ErrorCode.PLAN_NOT_FOUND)); validateUserAccess(studyPlan, userId); - // 단발성 계획 삭제 (반복 룰이 null이거나 applyScope가 null인 경우) + // 삭제 전 정보 조회 + StudyPlanResponse deletedPlan; + if (studyPlan.getRepeatRule() == null || applyScope == null) { + // 단발성 계획 + deletedPlan = new StudyPlanResponse(studyPlan); studyPlanRepository.delete(studyPlan); - return; + } else { + // 반복성 계획 - 가상 계획 조회 + deletedPlan = createVirtualPlanForDate(studyPlan, selectedDate); + if (deletedPlan == null) { + throw new CustomException(ErrorCode.PLAN_NOT_FOUND); + } + deleteRepeatPlan(studyPlan, selectedDate, applyScope); } - // 반복성 계획 삭제 - applyScope에 따른 처리 - deleteRepeatPlan(studyPlan, selectedDate, applyScope); + return new StudyPlanDeleteResponse(deletedPlan, applyScope); } private void deleteRepeatPlan(StudyPlan studyPlan, LocalDate selectedDate, ApplyScope applyScope) { @@ -617,5 +612,30 @@ private void validateRepeatRuleDate(StudyPlan studyPlan, LocalDate untilDate) { throw new CustomException(ErrorCode.REPEAT_INVALID_UNTIL_DATE); } } + // WEEKLY인 경우 빈 byDay 처리 메서드 (RepeatRule용) + private void getByDayInWeekly(StudyPlanRequest.RepeatRuleRequest request, StudyPlan studyPlan, RepeatRule repeatRule) { + // byDay 설정 (WEEKLY 인 경우에만) + if (request.getFrequency() == Frequency.WEEKLY) { + // 1. byDay가 없으면 시작일 요일을 자동으로 설정 + if(request.getByDay() == null || request.getByDay().isEmpty()) { + DayOfWeek startDay = DayOfWeek.valueOf(studyPlan.getStartDate().getDayOfWeek().name().substring(0,3)); + repeatRule.setByDay(List.of(startDay)); + } else { + // 2. byDay가 있다면 요청 값을 사용 + repeatRule.setByDay(request.getByDay()); + } + } + } + // WEEKLY인 경우 빈 byDay 처리 메서드 (RepeatRuleEmbeddable용 - 오버로딩) + private void getByDayInWeekly(StudyPlanRequest.RepeatRuleRequest request, LocalDateTime startDate, RepeatRuleEmbeddable embeddable) { + if (request.getFrequency() == Frequency.WEEKLY) { + if (request.getByDay() == null || request.getByDay().isEmpty()) { + DayOfWeek startDay = DayOfWeek.valueOf(startDate.getDayOfWeek().name().substring(0, 3)); + embeddable.setByDay(List.of(startDay)); + } else { + embeddable.setByDay(request.getByDay()); + } + } + } } diff --git a/src/main/java/com/back/domain/studyroom/controller/RoomController.java b/src/main/java/com/back/domain/studyroom/controller/RoomController.java index d14a70ea..2b7b4b7c 100644 --- a/src/main/java/com/back/domain/studyroom/controller/RoomController.java +++ b/src/main/java/com/back/domain/studyroom/controller/RoomController.java @@ -3,7 +3,9 @@ import com.back.domain.studyroom.dto.*; import com.back.domain.studyroom.entity.Room; import com.back.domain.studyroom.entity.RoomMember; +import com.back.domain.studyroom.entity.RoomRole; import com.back.domain.studyroom.service.RoomService; +import com.back.domain.user.entity.User; import com.back.global.common.dto.RsData; import com.back.global.security.user.CurrentUser; import io.swagger.v3.oas.annotations.Operation; @@ -43,7 +45,7 @@ public class RoomController { @PostMapping @Operation( summary = "방 생성", - description = "새로운 스터디 룸을 생성합니다. 방 생성자는 자동으로 방장(HOST)이 됩니다." + description = "새로운 스터디 룸을 생성합니다. 방 생성자는 자동으로 방장(HOST)이 됩니다. useWebRTC로 화상/음성/화면공유 기능을 한 번에 제어할 수 있습니다." ) @ApiResponses({ @ApiResponse(responseCode = "201", description = "방 생성 성공"), @@ -61,10 +63,11 @@ public ResponseEntity> createRoom( request.getIsPrivate() != null ? request.getIsPrivate() : false, request.getPassword(), request.getMaxParticipants() != null ? request.getMaxParticipants() : 10, - currentUserId + currentUserId, + request.getUseWebRTC() != null ? request.getUseWebRTC() : true // 디폴트: true ); - RoomResponse response = RoomResponse.from(room); + RoomResponse response = roomService.toRoomResponse(room); return ResponseEntity .status(HttpStatus.CREATED) @@ -139,9 +142,7 @@ public ResponseEntity>> getRooms( Pageable pageable = PageRequest.of(page, size); Page rooms = roomService.getJoinableRooms(pageable); - List roomList = rooms.getContent().stream() - .map(RoomResponse::from) - .collect(Collectors.toList()); + List roomList = roomService.toRoomResponseList(rooms.getContent()); Map response = new HashMap<>(); response.put("rooms", roomList); @@ -175,11 +176,7 @@ public ResponseEntity> getRoomDetail( Room room = roomService.getRoomDetail(roomId, currentUserId); List members = roomService.getRoomMembers(roomId, currentUserId); - List memberResponses = members.stream() - .map(RoomMemberResponse::from) - .collect(Collectors.toList()); - - RoomDetailResponse response = RoomDetailResponse.of(room, memberResponses); + RoomDetailResponse response = roomService.toRoomDetailResponse(room, members); return ResponseEntity .status(HttpStatus.OK) @@ -201,12 +198,7 @@ public ResponseEntity>> getMyRooms() { List rooms = roomService.getUserRooms(currentUserId); - List roomList = rooms.stream() - .map(room -> MyRoomResponse.of( - room, - roomService.getUserRoomRole(room.getId(), currentUserId) - )) - .collect(Collectors.toList()); + List roomList = roomService.toMyRoomResponseList(rooms, currentUserId); return ResponseEntity .status(HttpStatus.OK) @@ -313,9 +305,7 @@ public ResponseEntity>> getPopularRooms( Pageable pageable = PageRequest.of(page, size); Page rooms = roomService.getPopularRooms(pageable); - List roomList = rooms.getContent().stream() - .map(RoomResponse::from) - .collect(Collectors.toList()); + List roomList = roomService.toRoomResponseList(rooms.getContent()); Map response = new HashMap<>(); response.put("rooms", roomList); @@ -329,4 +319,44 @@ public ResponseEntity>> getPopularRooms( .status(HttpStatus.OK) .body(RsData.success("인기 방 목록 조회 완료", response)); } + + @PutMapping("/{roomId}/members/{userId}/role") + @Operation( + summary = "멤버 역할 변경", + description = "방 멤버의 역할을 변경합니다. 방장만 실행 가능합니다. VISITOR를 포함한 모든 사용자의 역할을 변경할 수 있으며, HOST로 변경 시 기존 방장은 자동으로 MEMBER로 강등됩니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "역할 변경 성공"), + @ApiResponse(responseCode = "400", description = "자신의 역할은 변경 불가"), + @ApiResponse(responseCode = "403", description = "방장 권한 없음"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 방 또는 사용자"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + public ResponseEntity> changeUserRole( + @Parameter(description = "방 ID", required = true) @PathVariable Long roomId, + @Parameter(description = "대상 사용자 ID", required = true) @PathVariable Long userId, + @Valid @RequestBody ChangeRoleRequest request) { + + Long currentUserId = currentUser.getUserId(); + + // 변경 전 역할 조회 + RoomRole oldRole = roomService.getUserRoomRole(roomId, userId); + + // 역할 변경 + roomService.changeUserRole(roomId, userId, request.getNewRole(), currentUserId); + + // 사용자 정보 조회 + User targetUser = roomService.getUserById(userId); + + ChangeRoleResponse response = ChangeRoleResponse.of( + userId, + targetUser.getNickname(), + oldRole, + request.getNewRole() + ); + + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success("역할 변경 완료", response)); + } } diff --git a/src/main/java/com/back/domain/studyroom/dto/ChangeRoleRequest.java b/src/main/java/com/back/domain/studyroom/dto/ChangeRoleRequest.java new file mode 100644 index 00000000..753ffb89 --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/dto/ChangeRoleRequest.java @@ -0,0 +1,21 @@ +package com.back.domain.studyroom.dto; + +import com.back.domain.studyroom.entity.RoomRole; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 멤버 역할 변경 요청 DTO + * - VISITOR → MEMBER/SUB_HOST/HOST 모두 가능 + * - HOST로 변경 시 기존 방장은 자동으로 MEMBER로 강등 + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class ChangeRoleRequest { + + @NotNull(message = "역할은 필수입니다") + private RoomRole newRole; +} diff --git a/src/main/java/com/back/domain/studyroom/dto/ChangeRoleResponse.java b/src/main/java/com/back/domain/studyroom/dto/ChangeRoleResponse.java new file mode 100644 index 00000000..a2c493ef --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/dto/ChangeRoleResponse.java @@ -0,0 +1,47 @@ +package com.back.domain.studyroom.dto; + +import com.back.domain.studyroom.entity.RoomRole; +import lombok.Builder; +import lombok.Getter; + +/** + * 역할 변경 응답 DTO + */ +@Getter +@Builder +public class ChangeRoleResponse { + + private Long userId; + private String nickname; + private RoomRole oldRole; + private RoomRole newRole; + private String message; + + public static ChangeRoleResponse of(Long userId, String nickname, + RoomRole oldRole, RoomRole newRole) { + String message = buildMessage(oldRole, newRole); + + return ChangeRoleResponse.builder() + .userId(userId) + .nickname(nickname) + .oldRole(oldRole) + .newRole(newRole) + .message(message) + .build(); + } + + private static String buildMessage(RoomRole oldRole, RoomRole newRole) { + if (newRole == RoomRole.HOST) { + return "방장으로 임명되었습니다."; + } else if (oldRole == RoomRole.HOST) { + return "방장 권한이 해제되었습니다."; + } else if (newRole == RoomRole.SUB_HOST) { + return "부방장으로 승격되었습니다."; + } else if (newRole == RoomRole.MEMBER && oldRole == RoomRole.VISITOR) { + return "정식 멤버로 승격되었습니다."; + } else if (newRole == RoomRole.MEMBER) { + return "일반 멤버로 강등되었습니다."; + } + return "역할이 변경되었습니다."; + } +} diff --git a/src/main/java/com/back/domain/studyroom/dto/CreateRoomRequest.java b/src/main/java/com/back/domain/studyroom/dto/CreateRoomRequest.java index 46a4672d..0dd78295 100644 --- a/src/main/java/com/back/domain/studyroom/dto/CreateRoomRequest.java +++ b/src/main/java/com/back/domain/studyroom/dto/CreateRoomRequest.java @@ -23,4 +23,10 @@ public class CreateRoomRequest { @Min(value = 2, message = "최소 2명 이상이어야 합니다") @Max(value = 100, message = "최대 100명까지 가능합니다") private Integer maxParticipants = 10; + + // WebRTC 통합 제어 필드 (카메라, 오디오, 화면공유를 한 번에 제어) + // true: WebRTC 기능 전체 활성화 + // false: WebRTC 기능 전체 비활성화 + // null: 디폴트 true로 처리 + private Boolean useWebRTC = true; } diff --git a/src/main/java/com/back/domain/studyroom/dto/MyRoomResponse.java b/src/main/java/com/back/domain/studyroom/dto/MyRoomResponse.java index 78b90d55..7a5c710d 100644 --- a/src/main/java/com/back/domain/studyroom/dto/MyRoomResponse.java +++ b/src/main/java/com/back/domain/studyroom/dto/MyRoomResponse.java @@ -20,12 +20,12 @@ public class MyRoomResponse { private RoomRole myRole; private LocalDateTime createdAt; - public static MyRoomResponse of(Room room, RoomRole myRole) { + public static MyRoomResponse of(Room room, long currentParticipants, RoomRole myRole) { return MyRoomResponse.builder() .roomId(room.getId()) .title(room.getTitle()) .description(room.getDescription() != null ? room.getDescription() : "") - .currentParticipants(room.getCurrentParticipants()) + .currentParticipants((int) currentParticipants) // Redis에서 조회한 실시간 값 .maxParticipants(room.getMaxParticipants()) .status(room.getStatus()) .myRole(myRole) diff --git a/src/main/java/com/back/domain/studyroom/dto/RoleChangedNotification.java b/src/main/java/com/back/domain/studyroom/dto/RoleChangedNotification.java new file mode 100644 index 00000000..10204b1b --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/dto/RoleChangedNotification.java @@ -0,0 +1,62 @@ +package com.back.domain.studyroom.dto; + +import com.back.domain.studyroom.entity.RoomRole; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +/** + * 역할 변경 WebSocket 알림 DTO + * - 방 멤버의 역할이 변경되었을 때 실시간 브로드캐스트 + */ +@Getter +@Builder +public class RoleChangedNotification { + + private Long roomId; + private Long userId; + private String nickname; + private String profileImageUrl; + private RoomRole oldRole; + private RoomRole newRole; + private String message; + private LocalDateTime timestamp; + + public static RoleChangedNotification of( + Long roomId, + Long userId, + String nickname, + String profileImageUrl, + RoomRole oldRole, + RoomRole newRole) { + + String message = buildMessage(nickname, oldRole, newRole); + + return RoleChangedNotification.builder() + .roomId(roomId) + .userId(userId) + .nickname(nickname) + .profileImageUrl(profileImageUrl) + .oldRole(oldRole) + .newRole(newRole) + .message(message) + .timestamp(LocalDateTime.now()) + .build(); + } + + private static String buildMessage(String nickname, RoomRole oldRole, RoomRole newRole) { + if (newRole == RoomRole.HOST) { + return String.format("%s님이 방장으로 임명되었습니다.", nickname); + } else if (oldRole == RoomRole.HOST) { + return String.format("%s님이 일반 멤버로 변경되었습니다.", nickname); + } else if (newRole == RoomRole.SUB_HOST) { + return String.format("%s님이 부방장으로 승격되었습니다.", nickname); + } else if (newRole == RoomRole.MEMBER && oldRole == RoomRole.VISITOR) { + return String.format("%s님이 정식 멤버로 승격되었습니다.", nickname); + } else if (newRole == RoomRole.MEMBER) { + return String.format("%s님이 일반 멤버로 변경되었습니다.", nickname); + } + return String.format("%s님의 역할이 변경되었습니다.", nickname); + } +} diff --git a/src/main/java/com/back/domain/studyroom/dto/RoomDetailResponse.java b/src/main/java/com/back/domain/studyroom/dto/RoomDetailResponse.java index f282d46e..803d99f8 100644 --- a/src/main/java/com/back/domain/studyroom/dto/RoomDetailResponse.java +++ b/src/main/java/com/back/domain/studyroom/dto/RoomDetailResponse.java @@ -25,14 +25,14 @@ public class RoomDetailResponse { private LocalDateTime createdAt; private List members; - public static RoomDetailResponse of(Room room, List members) { + public static RoomDetailResponse of(Room room, long currentParticipants, List members) { return RoomDetailResponse.builder() .roomId(room.getId()) .title(room.getTitle()) .description(room.getDescription() != null ? room.getDescription() : "") .isPrivate(room.isPrivate()) .maxParticipants(room.getMaxParticipants()) - .currentParticipants(room.getCurrentParticipants()) + .currentParticipants((int) currentParticipants) // Redis에서 조회한 실시간 값 .status(room.getStatus()) .allowCamera(room.isAllowCamera()) .allowAudio(room.isAllowAudio()) diff --git a/src/main/java/com/back/domain/studyroom/dto/RoomMemberResponse.java b/src/main/java/com/back/domain/studyroom/dto/RoomMemberResponse.java index 2d12c824..a6f0aa21 100644 --- a/src/main/java/com/back/domain/studyroom/dto/RoomMemberResponse.java +++ b/src/main/java/com/back/domain/studyroom/dto/RoomMemberResponse.java @@ -13,18 +13,18 @@ public class RoomMemberResponse { private Long userId; private String nickname; private RoomRole role; - private boolean isOnline; private LocalDateTime joinedAt; - private LocalDateTime lastActiveAt; + private LocalDateTime promotedAt; + + // TODO: isOnline은 Redis에서 조회하여 추가 예정 public static RoomMemberResponse from(RoomMember member) { return RoomMemberResponse.builder() .userId(member.getUser().getId()) .nickname(member.getUser().getNickname()) .role(member.getRole()) - .isOnline(member.isOnline()) .joinedAt(member.getJoinedAt()) - .lastActiveAt(member.getLastActiveAt() != null ? member.getLastActiveAt() : member.getJoinedAt()) + .promotedAt(member.getPromotedAt()) .build(); } } diff --git a/src/main/java/com/back/domain/studyroom/dto/RoomResponse.java b/src/main/java/com/back/domain/studyroom/dto/RoomResponse.java index 905a35ad..1f22a5aa 100644 --- a/src/main/java/com/back/domain/studyroom/dto/RoomResponse.java +++ b/src/main/java/com/back/domain/studyroom/dto/RoomResponse.java @@ -19,16 +19,24 @@ public class RoomResponse { private String createdBy; private LocalDateTime createdAt; - public static RoomResponse from(Room room) { + // WebRTC 설정 정보 (프론트엔드에서 UI 제어용) + private Boolean allowCamera; + private Boolean allowAudio; + private Boolean allowScreenShare; + + public static RoomResponse from(Room room, long currentParticipants) { return RoomResponse.builder() .roomId(room.getId()) .title(room.getTitle()) .description(room.getDescription() != null ? room.getDescription() : "") - .currentParticipants(room.getCurrentParticipants()) + .currentParticipants((int) currentParticipants) // Redis에서 조회한 실시간 값 .maxParticipants(room.getMaxParticipants()) .status(room.getStatus()) .createdBy(room.getCreatedBy().getNickname()) .createdAt(room.getCreatedAt()) + .allowCamera(room.isAllowCamera()) + .allowAudio(room.isAllowAudio()) + .allowScreenShare(room.isAllowScreenShare()) .build(); } } diff --git a/src/main/java/com/back/domain/studyroom/entity/Room.java b/src/main/java/com/back/domain/studyroom/entity/Room.java index 98d681cf..dea501d0 100644 --- a/src/main/java/com/back/domain/studyroom/entity/Room.java +++ b/src/main/java/com/back/domain/studyroom/entity/Room.java @@ -168,9 +168,11 @@ public boolean isOwner(Long userId) { * 방 생성을 위한 정적 팩토리 메서드 새로운 방을 생성할 때 모든 기본값을 설정 해주는 초기 메서드 기본 상태에서 방장이 임의로 변형하고 싶은 부분만 변경해서 사용 가능 + * @param useWebRTC WebRTC 사용 여부 (true: 카메라/오디오/화면공유 전체 활성화, false: 전체 비활성화) */ public static Room create(String title, String description, boolean isPrivate, - String password, int maxParticipants, User creator, RoomTheme theme) { + String password, int maxParticipants, User creator, RoomTheme theme, + boolean useWebRTC) { Room room = new Room(); room.title = title; room.description = description; @@ -178,9 +180,9 @@ public static Room create(String title, String description, boolean isPrivate, room.password = password; room.maxParticipants = maxParticipants; room.isActive = true; // 생성 시 기본적으로 활성화 - room.allowCamera = true; // 기본적으로 카메라 허용 - room.allowAudio = true; // 기본적으로 오디오 허용 - room.allowScreenShare = true; // 기본적으로 화면 공유 허용 + room.allowCamera = useWebRTC; // WebRTC 사용 여부에 따라 설정 + room.allowAudio = useWebRTC; // WebRTC 사용 여부에 따라 설정 + room.allowScreenShare = useWebRTC; // WebRTC 사용 여부에 따라 설정 room.status = RoomStatus.WAITING; // 생성 시 대기 상태 room.currentParticipants = 0; // 생성 시 참가자 0명 room.createdBy = creator; diff --git a/src/main/java/com/back/domain/studyroom/entity/RoomMember.java b/src/main/java/com/back/domain/studyroom/entity/RoomMember.java index e442c692..929425e9 100644 --- a/src/main/java/com/back/domain/studyroom/entity/RoomMember.java +++ b/src/main/java/com/back/domain/studyroom/entity/RoomMember.java @@ -41,16 +41,9 @@ public class RoomMember extends BaseEntity { // 멤버십 기본 정보 @Column(nullable = false) - private LocalDateTime joinedAt; // 방에 처음 입장한 시간 - private LocalDateTime lastActiveAt; // 마지막으로 활동한 시간 - - // 실시간 상태 관리 필드들 - @Column(nullable = false) - private boolean isOnline = false; // 현재 방에 온라인 상태인지 - - private String connectionId; // WebSocket 연결 ID (실시간 통신용) - - private LocalDateTime lastHeartbeat; // 마지막 heartbeat 시간 (연결 상태 확인용) + private LocalDateTime joinedAt; // MEMBER 이상으로 승격된 시간 + + private LocalDateTime promotedAt; // 권한이 변경된 시간 // 💡 권한 확인 메서드들 (RoomRole enum의 메서드를 위임) @@ -95,18 +88,20 @@ public boolean isMember() { } /** - * 현재 활성 상태인지 확인 - 온라인 멤버 목록 표시, 비활성 사용자 정리 등 - 온라인 상태이고 최근 설정된 시간 이내에 heartbeat가 있었던 경우 + * 현재 활성 상태인지 확인 (Redis 기반으로 변경 예정) + * 임시로 항상 true 반환 + * TODO: Redis에서 실시간 상태 확인하도록 변경 */ + @Deprecated public boolean isActive(int timeoutMinutes) { - return isOnline && lastHeartbeat != null && - lastHeartbeat.isAfter(LocalDateTime.now().minusMinutes(timeoutMinutes)); + // 실시간 상태는 Redis에서 관리 + return true; } /** - 기본 멤버 생성 메서드, 처음 입장 시 사용 + 기본 멤버 생성 메서드 + MEMBER 이상 등급 생성 시 사용 (DB 저장용) */ public static RoomMember create(Room room, User user, RoomRole role) { RoomMember member = new RoomMember(); @@ -114,9 +109,7 @@ public static RoomMember create(Room room, User user, RoomRole role) { member.user = user; member.role = role; member.joinedAt = LocalDateTime.now(); - member.lastActiveAt = LocalDateTime.now(); - member.isOnline = true; // 생성 시 온라인 상태 - member.lastHeartbeat = LocalDateTime.now(); + member.promotedAt = LocalDateTime.now(); return member; } @@ -128,18 +121,24 @@ public static RoomMember createHost(Room room, User user) { /** * 일반 멤버 생성, 권한 자동 변경 - - 비공개 방에서 초대받은 사용자를 정식 멤버로 등록할 때 (로직 검토 중) + * 비공개 방에서 초대받은 사용자를 정식 멤버로 등록할 때 */ public static RoomMember createMember(Room room, User user) { return create(room, user, RoomRole.MEMBER); } /** - * 방문객 생성 - * 사용 상황: 공개 방에 처음 입장하는 사용자를 임시 방문객으로 등록 + * 방문객 생성 (메모리상으로만 존재, DB 저장 안함) + * 공개 방에 처음 입장하는 사용자용 + * Redis에서 실시간 상태 관리 */ public static RoomMember createVisitor(Room room, User user) { - return create(room, user, RoomRole.VISITOR); + RoomMember member = new RoomMember(); + member.room = room; + member.user = user; + member.role = RoomRole.VISITOR; + member.joinedAt = LocalDateTime.now(); + return member; } /** @@ -148,47 +147,6 @@ public static RoomMember createVisitor(Room room, User user) { */ public void updateRole(RoomRole newRole) { this.role = newRole; - } - - /** - * 온라인 상태 변경 - * 사용 상황: 멤버가 방에 입장하거나 퇴장할 때 - 활동 시간도 함께 업데이트, 온라인이 되면 heartbeat도 갱신 - */ - public void updateOnlineStatus(boolean online) { - this.isOnline = online; - this.lastActiveAt = LocalDateTime.now(); - if (online) { - this.lastHeartbeat = LocalDateTime.now(); - } - } - - /** - * WebSocket 연결 ID 업데이트 - * 사용 상황: 멤버가 웹소켓으로 방에 연결될 때 - + heartbeat도 함께 갱신 - */ - public void updateConnectionId(String connectionId) { - this.connectionId = connectionId; - this.lastHeartbeat = LocalDateTime.now(); - } - - /** - * 사용 : 클라이언트에서 주기적으로 서버에 연결 상태를 알릴 때 - * 목적: 연결이 끊어진 멤버를 자동으로 감지하기 위해 사용, 별도의 다른 것으로 변경 가능 - */ - public void heartbeat() { - this.lastHeartbeat = LocalDateTime.now(); - this.lastActiveAt = LocalDateTime.now(); - this.isOnline = true; - } - - /** - * 방 퇴장 처리 (명시적 퇴장과 연결 끊김 상태 로직 분할 예정임.. 일단은 임시로 통합 상태) - 멤버가 방을 나가거나 연결이 끊어졌을 때, 오프라인 상태로 변경하고 연결 ID 제거 - */ - public void leave() { - this.isOnline = false; - this.connectionId = null; + this.promotedAt = LocalDateTime.now(); } } diff --git a/src/main/java/com/back/domain/studyroom/repository/RoomChatMessageRepositoryImpl.java b/src/main/java/com/back/domain/studyroom/repository/RoomChatMessageRepositoryImpl.java index 4f8dd70f..61e4e185 100644 --- a/src/main/java/com/back/domain/studyroom/repository/RoomChatMessageRepositoryImpl.java +++ b/src/main/java/com/back/domain/studyroom/repository/RoomChatMessageRepositoryImpl.java @@ -35,6 +35,7 @@ public Page findMessagesByRoomId(Long roomId, Pageable pageable .selectFrom(message) .leftJoin(message.room, room).fetchJoin() // Room 정보 즉시 로딩 .leftJoin(message.user, user).fetchJoin() // User 정보 즉시 로딩 + .leftJoin(user.userProfile).fetchJoin() // UserProfile 정보 즉시 로딩 .where(message.room.id.eq(roomId)) .orderBy(message.createdAt.desc()) // 최신순 정렬 .offset(pageable.getOffset()) @@ -64,6 +65,7 @@ public Page findMessagesByRoomIdBefore(Long roomId, LocalDateTi .selectFrom(message) .leftJoin(message.room, room).fetchJoin() .leftJoin(message.user, user).fetchJoin() + .leftJoin(user.userProfile).fetchJoin() .where(whereClause) .orderBy(message.createdAt.desc()) .offset(pageable.getOffset()) diff --git a/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepository.java b/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepository.java index 213f3890..367d8d9d 100644 --- a/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepository.java +++ b/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepository.java @@ -8,8 +8,5 @@ @Repository public interface RoomMemberRepository extends JpaRepository, RoomMemberRepositoryCustom { - /** - * WebSocket 연결 ID로 멤버 조회 - */ - Optional findByConnectionId(String connectionId); + // 모든 메서드는 RoomMemberRepositoryCustom 인터페이스로 이동 } diff --git a/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryCustom.java b/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryCustom.java index 7740e0d1..399c3254 100644 --- a/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryCustom.java +++ b/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryCustom.java @@ -20,16 +20,22 @@ public interface RoomMemberRepositoryCustom { /** * 방의 온라인 멤버 조회 + * TODO: Redis 기반으로 변경 예정 + * 현재는 DB에 저장된 모든 멤버 반환 (임시) */ + @Deprecated List findOnlineMembersByRoomId(Long roomId); /** * 방의 활성 멤버 수 조회 + * TODO: Redis 기반으로 변경 예정 */ + @Deprecated int countActiveMembersByRoomId(Long roomId); /** * 사용자가 참여 중인 모든 방의 멤버십 조회 + * DB에 저장된 멤버십만 조회 (MEMBER 이상) */ List findActiveByUserId(Long userId); @@ -58,18 +64,33 @@ public interface RoomMemberRepositoryCustom { */ boolean existsByRoomIdAndUserId(Long roomId, Long userId); + /** + * 여러 사용자의 멤버십 일괄 조회 (IN 절) + * Redis에서 온라인 사용자 목록을 받아서 DB 멤버십 조회 시 사용 + * @param roomId 방 ID + * @param userIds 사용자 ID 목록 + * @return 멤버십 목록 (MEMBER 이상만 DB에 있음) + */ + List findByRoomIdAndUserIdIn(Long roomId, java.util.Set userIds); + /** * 특정 역할의 멤버 수 조회 + * TODO: Redis 기반으로 변경 예정 */ + @Deprecated int countByRoomIdAndRole(Long roomId, RoomRole role); /** * 방 퇴장 처리 (벌크 업데이트) + * TODO: Redis로 이관 예정, DB에는 멤버십만 유지 */ + @Deprecated void leaveRoom(Long roomId, Long userId); /** * 방의 모든 멤버를 오프라인 처리 (방 종료 시) + * TODO: Redis로 이관 예정 */ + @Deprecated void disconnectAllMembers(Long roomId); } diff --git a/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryImpl.java b/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryImpl.java index 81bd17a7..c48f2aa6 100644 --- a/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryImpl.java +++ b/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryImpl.java @@ -74,48 +74,39 @@ public List findByRoomIdOrderByRole(Long roomId) { /** * 방의 온라인 멤버 조회 - * - 현재 온라인 상태인 멤버만 (isOnline = true) - * - 1순위: 역할 (HOST > SUB_HOST > MEMBER > VISITOR) - * - 2순위: 마지막 활동 시간 (최근 활동 순) - * - 방 상세 페이지에서 현재 접속 중인 멤버 표시 - * - 실시간 멤버 목록 업데이트 + * TODO: Redis 기반으로 변경 예정 + * 현재는 방의 모든 멤버 반환 (임시) * @param roomId 방 ID - * @return 온라인 멤버 목록 + * @return 멤버 목록 (역할순, 입장순 정렬) */ @Override + @Deprecated public List findOnlineMembersByRoomId(Long roomId) { return queryFactory .selectFrom(roomMember) .leftJoin(roomMember.user, user).fetchJoin() // N+1 방지 - .where( - roomMember.room.id.eq(roomId), - roomMember.isOnline.eq(true) - ) + .where(roomMember.room.id.eq(roomId)) .orderBy( - roomMember.role.asc(), // 역할순 - roomMember.lastActiveAt.desc() // 최근 활동순 + roomMember.role.asc(), // 역할순 + roomMember.joinedAt.asc() // 입장 시간순 ) .fetch(); } /** * 방의 활성 멤버 수 조회 - * - 현재 온라인 상태인 멤버 (isOnline = true) - * - 방 목록에서 현재 참가자 수 표시 - * - 정원 체크 (현재 참가자 vs 최대 참가자) - * - 통계 데이터 수집 로직 구현 시 연결 해야함.. + * TODO: Redis 기반으로 변경 예정 + * 현재는 방의 모든 멤버 수 반환 (임시) * @param roomId 방 ID - * @return 활성 멤버 수 + * @return 멤버 수 */ @Override + @Deprecated public int countActiveMembersByRoomId(Long roomId) { Long count = queryFactory .select(roomMember.count()) .from(roomMember) - .where( - roomMember.room.id.eq(roomId), - roomMember.isOnline.eq(true) - ) + .where(roomMember.room.id.eq(roomId)) .fetchOne(); return count != null ? count.intValue() : 0; @@ -123,17 +114,15 @@ public int countActiveMembersByRoomId(Long roomId) { /** * 사용자가 참여 중인 모든 방의 멤버십 조회 + * DB에 저장된 멤버십만 조회 (MEMBER 이상) * @param userId 사용자 ID - * @return 참여 중인 방의 멤버십 목록 + * @return 멤버십 목록 */ @Override public List findActiveByUserId(Long userId) { return queryFactory .selectFrom(roomMember) - .where( - roomMember.user.id.eq(userId), - roomMember.isOnline.eq(true) - ) + .where(roomMember.user.id.eq(userId)) .fetch(); } @@ -267,29 +256,50 @@ public boolean existsByRoomIdAndUserId(Long roomId, Long userId) { } /** - * 특정 역할의 온라인 멤버 수 조회 - * - 특정 역할의 멤버 - * - 현재 온라인 상태만 - * 예시: - * ```java - * int hostCount = countByRoomIdAndRole(roomId, RoomRole.HOST); - * if (hostCount == 0) { - * - * } - * ``` + * 여러 사용자의 멤버십 일괄 조회 (IN 절) + * - Redis 온라인 목록으로 DB 멤버십 조회 + * - N+1 문제 해결 + * - VISITOR는 DB에 없으므로 결과에 포함 안됨 + * @param roomId 방 ID + * @param userIds 사용자 ID Set + * @return DB에 저장된 멤버 목록 (MEMBER 이상) + */ + @Override + public List findByRoomIdAndUserIdIn(Long roomId, java.util.Set userIds) { + if (userIds == null || userIds.isEmpty()) { + return List.of(); + } + + return queryFactory + .selectFrom(roomMember) + .leftJoin(roomMember.user, user).fetchJoin() // N+1 방지 + .where( + roomMember.room.id.eq(roomId), + roomMember.user.id.in(userIds) + ) + .orderBy( + roomMember.role.asc(), // 역할순 + roomMember.joinedAt.asc() // 입장 시간순 + ) + .fetch(); + } + + /** + * 특정 역할의 멤버 수 조회 + * TODO: Redis 기반으로 변경 예정 * @param roomId 방 ID * @param role 역할 - * @return 해당 역할의 온라인 멤버 수 + * @return 해당 역할의 멤버 수 */ @Override + @Deprecated public int countByRoomIdAndRole(Long roomId, RoomRole role) { Long count = queryFactory .select(roomMember.count()) .from(roomMember) .where( roomMember.room.id.eq(roomId), - roomMember.role.eq(role), - roomMember.isOnline.eq(true) + roomMember.role.eq(role) ) .fetchOne(); @@ -298,75 +308,27 @@ public int countByRoomIdAndRole(Long roomId, RoomRole role) { /** * 방 퇴장 처리 (벌크 업데이트) - * - isOnline을 false로 변경 - * - connectionId를 null로 초기화 - * - * ai 코드 리뷰 결과 : - * - 한 번의 쿼리로 처리하여 성능 최적화 상태 - * - 벌크 연산은 영속성 컨텍스트를 무시 - * - 이후 해당 엔티티를 조회하면 DB와 불일치 가능 - * - 필요시 em.clear() 또는 em.refresh() 사용 - * ( 추후 기초 기능 개발 완료 후 개선 예정) - * - * - 사용자가 명시적으로 방을 나갈 때 - * - WebSocket 연결 끊김 감지 시 - * - 타임아웃으로 자동 퇴장 처리 시 + * TODO: Redis로 이관 예정 + * 현재는 아무 동작 안함 (DB에는 멤버십 유지) * @param roomId 방 ID * @param userId 사용자 ID */ @Override + @Deprecated public void leaveRoom(Long roomId, Long userId) { - queryFactory - .update(roomMember) - .set(roomMember.isOnline, false) - .setNull(roomMember.connectionId) - .where( - roomMember.room.id.eq(roomId), - roomMember.user.id.eq(userId) - ) - .execute(); + // Redis로 이관 예정 - 현재는 아무 동작 안함 + // DB의 멤버십은 유지됨 } /** * 방의 모든 멤버를 오프라인 처리 (방 종료 시) - * - 해당 방의 모든 멤버를 오프라인으로 변경 - * - 모든 멤버의 connectionId 제거 - * - * - 방장이 방을 종료할 때 - * - 방이 자동으로 종료될 때 (참가자 0명 + 일정 시간 경과) - * - 긴급 상황으로 방을 강제 종료할 때 - * - * ai 코드 리뷰 결과 : - * 해당 부분도 쿼리 한번으로 동작되는 내용이기 때문에, - * 그렇게 동작 시에는 웹소켓에 미리 종료 알림을 주는 형식으로 변경하라고 함. - * 이 작업 후 방 상태를 TERMINATED로 변경해야 함 - * - * 사용 예시: - * ```java - * @Transactional - * public void terminateRoom(Long roomId) { - * Room room = roomRepository.findById(roomId)...; - * - * // 모든 멤버 오프라인 처리 - * roomMemberRepository.disconnectAllMembers(roomId); - * - * // 방 종료 - * room.terminate(); - * - * // WebSocket으로 종료 알림 - * notifyRoomTermination(roomId); - * } - * ``` - * + * TODO: Redis로 이관 예정 + * 현재는 아무 동작 안함 * @param roomId 방 ID */ @Override + @Deprecated public void disconnectAllMembers(Long roomId) { - queryFactory - .update(roomMember) - .set(roomMember.isOnline, false) - .setNull(roomMember.connectionId) - .where(roomMember.room.id.eq(roomId)) - .execute(); + // Redis로 이관 예정 - 현재는 아무 동작 안함 } } diff --git a/src/main/java/com/back/domain/studyroom/repository/RoomRepository.java b/src/main/java/com/back/domain/studyroom/repository/RoomRepository.java index dea97567..9c11e14f 100644 --- a/src/main/java/com/back/domain/studyroom/repository/RoomRepository.java +++ b/src/main/java/com/back/domain/studyroom/repository/RoomRepository.java @@ -39,9 +39,11 @@ public interface RoomRepository extends JpaRepository, RoomRepositor Optional findByIdAndPassword(@Param("roomId") Long roomId, @Param("password") String password); // 참가자 수 업데이트 + // TODO: Redis 기반으로 변경 예정 - 현재는 사용하지 않음 + @Deprecated @Modifying @Query("UPDATE Room r SET r.currentParticipants = " + - "(SELECT COUNT(rm) FROM RoomMember rm WHERE rm.room.id = r.id AND rm.isOnline = true) " + + "(SELECT COUNT(rm) FROM RoomMember rm WHERE rm.room.id = r.id) " + "WHERE r.id = :roomId") void updateCurrentParticipants(@Param("roomId") Long roomId); } diff --git a/src/main/java/com/back/domain/studyroom/repository/RoomRepositoryImpl.java b/src/main/java/com/back/domain/studyroom/repository/RoomRepositoryImpl.java index bf0e81c2..d9ab9ac0 100644 --- a/src/main/java/com/back/domain/studyroom/repository/RoomRepositoryImpl.java +++ b/src/main/java/com/back/domain/studyroom/repository/RoomRepositoryImpl.java @@ -76,9 +76,9 @@ public Page findJoinablePublicRooms(Pageable pageable) { /** * 사용자가 참여 중인 방 조회 - * 조회 조건: - * - 특정 사용자가 멤버로 등록된 방 - * - 현재 온라인 상태인 방만 + * 조회 조건: + * - 특정 사용자가 멤버로 등록된 방 (DB에 저장된 멤버십) + * TODO: Redis에서 온라인 상태 확인하도록 변경 * @param userId 사용자 ID * @return 참여 중인 방 목록 */ @@ -88,10 +88,7 @@ public List findRoomsByUserId(Long userId) { .selectFrom(room) .leftJoin(room.createdBy, user).fetchJoin() // N+1 방지 .join(room.roomMembers, roomMember) // 멤버 조인 - .where( - roomMember.user.id.eq(userId), - roomMember.isOnline.eq(true) - ) + .where(roomMember.user.id.eq(userId)) .fetch(); } diff --git a/src/main/java/com/back/domain/studyroom/service/RoomRedisService.java b/src/main/java/com/back/domain/studyroom/service/RoomRedisService.java new file mode 100644 index 00000000..f08f1d11 --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/service/RoomRedisService.java @@ -0,0 +1,113 @@ +package com.back.domain.studyroom.service; + +import com.back.global.websocket.service.WebSocketSessionManager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.Map; +import java.util.Set; + +/** + * 방 상태 관리를 위한 Redis 전용 서비스 (곧 사라질 예정인 파일) + * (현재는 일단 유지 시킨 상황, 에러 방지용) + * @deprecated RoomParticipantService를 사용. + * 현재는 WebSocketSessionManager의 Wrapper일 뿐이며, + * RoomParticipantService에 원래 로직이 옮겨졋습니다. + * + * @see com.back.global.websocket.service.RoomParticipantService 실제 사용 서비스 + * @see com.back.global.websocket.service.WebSocketSessionManager WebSocket 세션 관리 + */ +@Deprecated +@Slf4j +@Service +@RequiredArgsConstructor +public class RoomRedisService { + + private final WebSocketSessionManager sessionManager; + + // ==================== 방 입장/퇴장 ==================== + + /** + * 사용자가 방에 입장 (Redis 온라인 상태 업데이트) + * - Redis Set에 userId 추가 + * - 역할(Role)은 DB에서만 관리 + * + * @param userId 사용자 ID + * @param roomId 방 ID + */ + public void enterRoom(Long userId, Long roomId) { + sessionManager.joinRoom(userId, roomId); + log.info("방 입장 완료 (Redis) - 사용자: {}, 방: {}", userId, roomId); + } + + /** + * 사용자가 방에서 퇴장 (Redis 온라인 상태 업데이트) + * - Redis Set에서 userId 제거 + * - DB 멤버십은 유지됨 (재입장 시 역할 유지) + * + * @param userId 사용자 ID + * @param roomId 방 ID + */ + public void exitRoom(Long userId, Long roomId) { + sessionManager.leaveRoom(userId, roomId); + log.info("방 퇴장 완료 (Redis) - 사용자: {}, 방: {}", userId, roomId); + } + + // ==================== 조회 ==================== + + /** + * 방의 현재 온라인 사용자 수 조회 + * - 실시간 참가자 수 (DB currentParticipants와 무관) + * + * @param roomId 방 ID + * @return 온라인 사용자 수 + */ + public long getRoomUserCount(Long roomId) { + return sessionManager.getRoomOnlineUserCount(roomId); + } + + /** + * 방의 온라인 사용자 ID 목록 조회 + * - 멤버 목록 조회 시 이 ID로 DB 조회 + * - DB에 없는 ID = VISITOR + * + * @param roomId 방 ID + * @return 온라인 사용자 ID Set + */ + public Set getRoomUsers(Long roomId) { + return sessionManager.getOnlineUsersInRoom(roomId); + } + + /** + * 사용자가 현재 특정 방에 있는지 확인 + * + * @param userId 사용자 ID + * @param roomId 방 ID + * @return 온라인 여부 + */ + public boolean isUserInRoom(Long userId, Long roomId) { + return sessionManager.isUserInRoom(userId, roomId); + } + + /** + * 사용자의 현재 방 ID 조회 + * + * @param userId 사용자 ID + * @return 방 ID (없으면 null) + */ + public Long getCurrentRoomId(Long userId) { + return sessionManager.getUserCurrentRoomId(userId); + } + + /** + * 여러 방의 온라인 사용자 수 일괄 조회 (N+1 방지) + * - 방 목록 조회 시 사용 + * + * @param roomIds 방 ID 목록 + * @return Map + */ + public Map getBulkRoomOnlineUserCounts(java.util.List roomIds) { + return sessionManager.getBulkRoomOnlineUserCounts(roomIds); + } +} diff --git a/src/main/java/com/back/domain/studyroom/service/RoomService.java b/src/main/java/com/back/domain/studyroom/service/RoomService.java index c7d021e4..fd2281d7 100644 --- a/src/main/java/com/back/domain/studyroom/service/RoomService.java +++ b/src/main/java/com/back/domain/studyroom/service/RoomService.java @@ -7,6 +7,7 @@ import com.back.domain.user.repository.UserRepository; import com.back.global.exception.CustomException; import com.back.global.exception.ErrorCode; +import com.back.global.websocket.service.RoomParticipantService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; @@ -16,6 +17,7 @@ import java.util.List; import java.util.Optional; +import java.util.Set; /** - 방 생성, 입장, 퇴장 로직 처리 @@ -40,6 +42,8 @@ public class RoomService { private final RoomMemberRepository roomMemberRepository; private final UserRepository userRepository; private final StudyRoomProperties properties; + private final RoomParticipantService roomParticipantService; + private final org.springframework.messaging.simp.SimpMessagingTemplate messagingTemplate; /** * 방 생성 메서드 @@ -51,26 +55,26 @@ public class RoomService { * 기본 설정: - 상태: WAITING (대기 중) - - 카메라/오디오/화면공유: application.yml의 설정값 사용 + - WebRTC: useWebRTC 파라미터에 따라 카메라/오디오/화면공유 통합 제어 - 참가자 수: 0명에서 시작 후 방장 추가로 1명 */ @Transactional public Room createRoom(String title, String description, boolean isPrivate, - String password, int maxParticipants, Long creatorId) { + String password, int maxParticipants, Long creatorId, boolean useWebRTC) { User creator = userRepository.findById(creatorId) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); - Room room = Room.create(title, description, isPrivate, password, maxParticipants, creator, null); + Room room = Room.create(title, description, isPrivate, password, maxParticipants, creator, null, useWebRTC); Room savedRoom = roomRepository.save(room); RoomMember hostMember = RoomMember.createHost(savedRoom, creator); roomMemberRepository.save(hostMember); - savedRoom.incrementParticipant(); + // savedRoom.incrementParticipant(); // Redis로 이관 - DB 업데이트 제거 - log.info("방 생성 완료 - RoomId: {}, Title: {}, CreatorId: {}", - savedRoom.getId(), title, creatorId); + log.info("방 생성 완료 - RoomId: {}, Title: {}, CreatorId: {}, WebRTC: {}", + savedRoom.getId(), title, creatorId, useWebRTC); return savedRoom; } @@ -81,13 +85,13 @@ public Room createRoom(String title, String description, boolean isPrivate, * 입장 검증 과정: * 1. 방 존재 및 활성 상태 확인 (비관적 락으로 동시성 제어) * 2. 방 상태가 입장 가능한지 확인 (WAITING, ACTIVE) - * 3. 정원 초과 여부 확인 + * 3. 정원 초과 여부 확인 (Redis 기반) * 4. 비공개 방인 경우 비밀번호 확인 * 5. 이미 참여 중인지 확인 (재입장 처리) - * 멤버 등록: (현재는 visitor로 등록이지만 추후 역할 부여가 안된 인원을 visitor로 띄우는 식으로 저장 데이터 줄일 예정) - * - 신규 사용자: VISITOR 역할로 등록 - * - 기존 사용자: 온라인 상태로 변경 + * 멤버 등록: + * - 신규 사용자 (DB에 없음): VISITOR로 입장 → DB 저장 안함, Redis에만 등록 + * - 기존 멤버 (DB에 있음): 저장된 역할로 재입장 → Redis에만 등록 * * 동시성 제어: 비관적 락(PESSIMISTIC_WRITE)으로 정원 초과 방지 */ @@ -106,10 +110,15 @@ public RoomMember joinRoom(Long roomId, String password, Long userId) { throw new CustomException(ErrorCode.ROOM_TERMINATED); } + // Redis에서 현재 온라인 사용자 수 조회 + long currentOnlineCount = roomParticipantService.getParticipantCount(roomId); + + // 정원 확인 (Redis 기반) + if (currentOnlineCount >= room.getMaxParticipants()) { + throw new CustomException(ErrorCode.ROOM_FULL); + } + if (!room.canJoin()) { - if (room.isFull()) { - throw new CustomException(ErrorCode.ROOM_FULL); - } throw new CustomException(ErrorCode.ROOM_INACTIVE); } @@ -122,36 +131,41 @@ public RoomMember joinRoom(Long roomId, String password, Long userId) { Optional existingMember = roomMemberRepository.findByRoomIdAndUserId(roomId, userId); if (existingMember.isPresent()) { + // 기존 멤버 재입장: DB에 있는 역할 그대로 사용 RoomMember member = existingMember.get(); - if (member.isOnline()) { - throw new CustomException(ErrorCode.ALREADY_JOINED_ROOM); - } - member.updateOnlineStatus(true); - room.incrementParticipant(); + + // Redis에 온라인 등록 + roomParticipantService.enterRoom(userId, roomId); + + log.info("기존 멤버 재입장 - RoomId: {}, UserId: {}, Role: {}", + roomId, userId, member.getRole()); + return member; } - RoomMember newMember = RoomMember.createVisitor(room, user); - RoomMember savedMember = roomMemberRepository.save(newMember); - - room.incrementParticipant(); + // 신규 입장자: VISITOR로 입장 (DB 저장 안함!) + RoomMember visitorMember = RoomMember.createVisitor(room, user); + + // Redis에만 온라인 등록 + roomParticipantService.enterRoom(userId, roomId); - log.info("방 입장 완료 - RoomId: {}, UserId: {}, Role: {}", - roomId, userId, newMember.getRole()); + log.info("신규 입장 (VISITOR) - RoomId: {}, UserId: {}, DB 저장 안함", roomId, userId); - return savedMember; + // 메모리상 객체 반환 (DB에 저장되지 않음) + return visitorMember; } /** * 방 나가기 메서드 * - * 🚪 퇴장 처리: - * - 일반 멤버: 단순 오프라인 처리 및 참가자 수 감소 - * - 방장: 특별 처리 로직 실행 (handleHostLeaving) + * 퇴장 처리: + * - VISITOR: Redis에서만 제거 (DB에 없음) + * - MEMBER 이상: Redis에서 제거 + DB 멤버십은 유지 (재입장 시 역할 유지) + * - 방장: Redis에서 제거 + DB 멤버십 유지 + 방은 계속 존재 * - * 🔄 방장 퇴장 시 처리: - * - 다른 멤버가 없으면 → 방 자동 종료 - * - 다른 멤버가 있으면 → 새 방장 자동 위임 + * 방은 참가자 0명이어도 유지: + * - 방장이 오프라인이어도 다른 사람들이 입장 가능 + * - 방 종료는 오직 방장만 명시적으로 가능 */ @Transactional public void leaveRoom(Long roomId, Long userId) { @@ -159,53 +173,12 @@ public void leaveRoom(Long roomId, Long userId) { Room room = roomRepository.findById(roomId) .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); - RoomMember member = roomMemberRepository.findByRoomIdAndUserId(roomId, userId) - .orElseThrow(() -> new CustomException(ErrorCode.NOT_ROOM_MEMBER)); - - if (!member.isOnline()) { - return; - } - - if (member.isHost()) { - handleHostLeaving(room, member); - } else { - member.leave(); - room.decrementParticipant(); - } + // Redis에서 퇴장 처리 (모든 사용자) + roomParticipantService.exitRoom(userId, roomId); log.info("방 퇴장 완료 - RoomId: {}, UserId: {}", roomId, userId); } - private void handleHostLeaving(Room room, RoomMember hostMember) { - List onlineMembers = roomMemberRepository.findOnlineMembersByRoomId(room.getId()); - - List otherOnlineMembers = onlineMembers.stream() - .filter(m -> !m.getId().equals(hostMember.getId())) - .toList(); - - if (otherOnlineMembers.isEmpty()) { - room.terminate(); - hostMember.leave(); - room.decrementParticipant(); - } else { - RoomMember newHost = otherOnlineMembers.stream() - .filter(m -> m.getRole() == RoomRole.SUB_HOST) - .findFirst() - .orElse(otherOnlineMembers.stream() - .min((m1, m2) -> m1.getJoinedAt().compareTo(m2.getJoinedAt())) - .orElse(null)); - - if (newHost != null) { - newHost.updateRole(RoomRole.HOST); - hostMember.leave(); - room.decrementParticipant(); - - log.info("새 방장 지정 - RoomId: {}, NewHostId: {}", - room.getId(), newHost.getUser().getId()); - } - } - } - public Page getJoinableRooms(Pageable pageable) { return roomRepository.findJoinablePublicRooms(pageable); } @@ -261,34 +234,118 @@ public void terminateRoom(Long roomId, Long userId) { } room.terminate(); - roomMemberRepository.disconnectAllMembers(roomId); - log.info("방 종료 완료 - RoomId: {}, UserId: {}", roomId, userId); + // Redis에서 모든 온라인 사용자 제거 + Set onlineUserIds = roomParticipantService.getParticipants(roomId); + for (Long onlineUserId : onlineUserIds) { + roomParticipantService.exitRoom(onlineUserId, roomId); + } + + log.info("방 종료 완료 - RoomId: {}, UserId: {}, 퇴장 처리: {}명", + roomId, userId, onlineUserIds.size()); } + /** + * 멤버 역할 변경 + * 1. 방장만 역할 변경 가능 + * 2. VISITOR → 모든 역할 승격 가능 (HOST 포함) + * 3. HOST로 변경 시: + * - 대상자가 DB에 없으면 DB에 저장 + * - 기존 방장은 자동으로 MEMBER로 강등 + * - 본인은 방장으로 변경 불가 + * 4. 방장 자신의 역할은 변경 불가 + * @param roomId 방 ID + * @param targetUserId 대상 사용자 ID + * @param newRole 새 역할 + * @param requesterId 요청자 ID (방장) + */ @Transactional public void changeUserRole(Long roomId, Long targetUserId, RoomRole newRole, Long requesterId) { + // 1. 요청자가 방장인지 확인 RoomMember requester = roomMemberRepository.findByRoomIdAndUserId(roomId, requesterId) .orElseThrow(() -> new CustomException(ErrorCode.NOT_ROOM_MEMBER)); - if (!requester.canManageRoom()) { + if (!requester.isHost()) { throw new CustomException(ErrorCode.NOT_ROOM_MANAGER); } - RoomMember targetMember = roomMemberRepository.findByRoomIdAndUserId(roomId, targetUserId) - .orElseThrow(() -> new CustomException(ErrorCode.NOT_ROOM_MEMBER)); + // 2. 본인을 변경하려는 경우 (방장 → 다른 역할 불가) + if (targetUserId.equals(requesterId)) { + throw new CustomException(ErrorCode.CANNOT_CHANGE_OWN_ROLE); + } - if (targetMember.isHost()) { - throw new CustomException(ErrorCode.CANNOT_CHANGE_HOST_ROLE); + // 3. 대상자 확인 (DB 조회 - VISITOR는 DB에 없을 수 있음) + Optional targetMemberOpt = roomMemberRepository.findByRoomIdAndUserId(roomId, targetUserId); + + // 변경 전 역할 저장 (알림용) + RoomRole oldRole = targetMemberOpt.map(RoomMember::getRole).orElse(RoomRole.VISITOR); + + // 4. HOST로 변경하는 경우 - 기존 방장 강등 + if (newRole == RoomRole.HOST) { + // 기존 방장을 MEMBER로 강등 + requester.updateRole(RoomRole.MEMBER); + log.info("기존 방장 강등 - RoomId: {}, UserId: {}, MEMBER로 변경", roomId, requesterId); } - targetMember.updateRole(newRole); + // 5. 대상자 처리 + if (targetMemberOpt.isPresent()) { + // 기존 멤버 - 역할만 업데이트 + RoomMember targetMember = targetMemberOpt.get(); + targetMember.updateRole(newRole); + + log.info("멤버 권한 변경 - RoomId: {}, TargetUserId: {}, NewRole: {}", + roomId, targetUserId, newRole); + } else { + // VISITOR → 승격 시 DB에 저장 + Room room = roomRepository.findById(roomId) + .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); + + User targetUser = userRepository.findById(targetUserId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + // DB에 저장 (처음으로!) + RoomMember newMember = RoomMember.create(room, targetUser, newRole); + roomMemberRepository.save(newMember); + + log.info("VISITOR 승격 (DB 저장) - RoomId: {}, UserId: {}, NewRole: {}", + roomId, targetUserId, newRole); + } + + // 6. WebSocket으로 역할 변경 알림 브로드캐스트 + User targetUser = userRepository.findById(targetUserId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + com.back.domain.studyroom.dto.RoleChangedNotification notification = + com.back.domain.studyroom.dto.RoleChangedNotification.of( + roomId, + targetUserId, + targetUser.getNickname(), + targetUser.getProfileImageUrl(), + oldRole, + newRole + ); + + messagingTemplate.convertAndSend( + "/topic/room/" + roomId + "/role-changed", + notification + ); - log.info("멤버 권한 변경 완료 - RoomId: {}, TargetUserId: {}, NewRole: {}, RequesterId: {}", - roomId, targetUserId, newRole, requesterId); + log.info("역할 변경 알림 전송 완료 - RoomId: {}, UserId: {}, {} → {}", + roomId, targetUserId, oldRole, newRole); } + /** + * 방 멤버 목록 조회 (Redis + DB 조합) + * 1. Redis에서 온라인 사용자 ID 조회 + * 2. DB에서 해당 사용자들의 멤버십 조회 (IN 절) + * 3. DB에 없는 사용자 = VISITOR + * 4. User 정보와 조합하여 반환 + * + * @param roomId 방 ID + * @param userId 요청자 ID (권한 체크용) + * @return 온라인 멤버 목록 (VISITOR 포함) + */ public List getRoomMembers(Long roomId, Long userId) { Room room = roomRepository.findById(roomId) @@ -301,7 +358,43 @@ public List getRoomMembers(Long roomId, Long userId) { } } - return roomMemberRepository.findOnlineMembersByRoomId(roomId); + // 1. Redis에서 온라인 사용자 ID 조회 + Set onlineUserIds = roomParticipantService.getParticipants(roomId); + + if (onlineUserIds.isEmpty()) { + return List.of(); + } + + // 2. DB에서 멤버십 조회 (MEMBER 이상만 DB에 있음) + List dbMembers = roomMemberRepository.findByRoomIdAndUserIdIn(roomId, onlineUserIds); + + // 3. DB에 있는 userId Set 생성 + Set dbUserIds = dbMembers.stream() + .map(m -> m.getUser().getId()) + .collect(java.util.stream.Collectors.toSet()); + + // 4. DB에 없는 userId = VISITOR들 + Set visitorUserIds = onlineUserIds.stream() + .filter(id -> !dbUserIds.contains(id)) + .collect(java.util.stream.Collectors.toSet()); + + // 5. VISITOR User 정보 조회 (일괄 조회) + if (!visitorUserIds.isEmpty()) { + List visitorUsers = userRepository.findAllById(visitorUserIds); + + // 6. VISITOR RoomMember 객체 생성 (메모리상) + List visitorMembers = visitorUsers.stream() + .map(user -> RoomMember.createVisitor(room, user)) + .collect(java.util.stream.Collectors.toList()); + + // 7. DB 멤버 + VISITOR 합치기 + List allMembers = new java.util.ArrayList<>(dbMembers); + allMembers.addAll(visitorMembers); + + return allMembers; + } + + return dbMembers; } public RoomRole getUserRoomRole(Long roomId, Long userId) { @@ -310,6 +403,14 @@ public RoomRole getUserRoomRole(Long roomId, Long userId) { .orElse(RoomRole.VISITOR); } + /** + * 사용자 정보 조회 (역할 변경 응답용) + */ + public User getUserById(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + } + /** * 인기 방 목록 조회 (참가자 수 기준) */ @@ -337,13 +438,90 @@ public void kickMember(Long roomId, Long targetUserId, Long requesterId) { throw new CustomException(ErrorCode.CANNOT_KICK_HOST); } - targetMember.leave(); - - Room room = roomRepository.findById(roomId) - .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); - room.decrementParticipant(); + // Redis에서 제거 (강제 퇴장) + roomParticipantService.exitRoom(targetUserId, roomId); log.info("멤버 추방 완료 - RoomId: {}, TargetUserId: {}, RequesterId: {}", roomId, targetUserId, requesterId); } + + // ==================== DTO 생성 헬퍼 메서드 ==================== + + /** + * RoomResponse 생성 (Redis에서 실시간 참가자 수 조회) + */ + public com.back.domain.studyroom.dto.RoomResponse toRoomResponse(Room room) { + long onlineCount = roomParticipantService.getParticipantCount(room.getId()); + return com.back.domain.studyroom.dto.RoomResponse.from(room, onlineCount); + } + + /** + * RoomResponse 리스트 생성 (일괄 조회로 N+1 방지) + */ + public java.util.List toRoomResponseList(java.util.List rooms) { + java.util.List roomIds = rooms.stream() + .map(Room::getId) + .collect(java.util.stream.Collectors.toList()); + + java.util.Map participantCounts = roomIds.stream() + .collect(java.util.stream.Collectors.toMap( + roomId -> roomId, + roomId -> roomParticipantService.getParticipantCount(roomId) + )); + + return rooms.stream() + .map(room -> com.back.domain.studyroom.dto.RoomResponse.from( + room, + participantCounts.getOrDefault(room.getId(), 0L) + )) + .collect(java.util.stream.Collectors.toList()); + } + + /** + * RoomDetailResponse 생성 (Redis에서 실시간 참가자 수 조회) + */ + public com.back.domain.studyroom.dto.RoomDetailResponse toRoomDetailResponse( + Room room, + java.util.List members) { + long onlineCount = roomParticipantService.getParticipantCount(room.getId()); + + java.util.List memberResponses = members.stream() + .map(com.back.domain.studyroom.dto.RoomMemberResponse::from) + .collect(java.util.stream.Collectors.toList()); + + return com.back.domain.studyroom.dto.RoomDetailResponse.of(room, onlineCount, memberResponses); + } + + /** + * MyRoomResponse 생성 (Redis에서 실시간 참가자 수 조회) + */ + public com.back.domain.studyroom.dto.MyRoomResponse toMyRoomResponse(Room room, RoomRole myRole) { + long onlineCount = roomParticipantService.getParticipantCount(room.getId()); + return com.back.domain.studyroom.dto.MyRoomResponse.of(room, onlineCount, myRole); + } + + /** + * MyRoomResponse 리스트 생성 (일괄 조회로 N+1 방지) + */ + public java.util.List toMyRoomResponseList( + java.util.List rooms, + Long userId) { + java.util.List roomIds = rooms.stream() + .map(Room::getId) + .collect(java.util.stream.Collectors.toList()); + + java.util.Map participantCounts = roomIds.stream() + .collect(java.util.stream.Collectors.toMap( + roomId -> roomId, + roomId -> roomParticipantService.getParticipantCount(roomId) + )); + + return rooms.stream() + .map(room -> { + RoomRole role = getUserRoomRole(room.getId(), userId); + long count = participantCounts.getOrDefault(room.getId(), 0L); + return com.back.domain.studyroom.dto.MyRoomResponse.of(room, count, role); + }) + .collect(java.util.stream.Collectors.toList()); + } } diff --git a/src/main/java/com/back/global/exception/ErrorCode.java b/src/main/java/com/back/global/exception/ErrorCode.java index a948a8b6..67266f4a 100644 --- a/src/main/java/com/back/global/exception/ErrorCode.java +++ b/src/main/java/com/back/global/exception/ErrorCode.java @@ -32,9 +32,10 @@ public enum ErrorCode { NOT_ROOM_MANAGER(HttpStatus.FORBIDDEN, "ROOM_009", "방 관리자 권한이 필요합니다."), CANNOT_KICK_HOST(HttpStatus.BAD_REQUEST, "ROOM_010", "방장은 추방할 수 없습니다."), CANNOT_CHANGE_HOST_ROLE(HttpStatus.BAD_REQUEST, "ROOM_011", "방장의 권한은 변경할 수 없습니다."), - CHAT_DELETE_FORBIDDEN(HttpStatus.FORBIDDEN, "ROOM_012", "채팅 삭제 권한이 없습니다. 방장 또는 부방장만 가능합니다."), - INVALID_DELETE_CONFIRMATION(HttpStatus.BAD_REQUEST, "ROOM_013", "삭제 확인 메시지가 일치하지 않습니다."), - CHAT_DELETE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "ROOM_014", "채팅 삭제 중 오류가 발생했습니다."), + CANNOT_CHANGE_OWN_ROLE(HttpStatus.BAD_REQUEST, "ROOM_012", "자신의 역할은 변경할 수 없습니다."), + CHAT_DELETE_FORBIDDEN(HttpStatus.FORBIDDEN, "ROOM_013", "채팅 삭제 권한이 없습니다. 방장 또는 부방장만 가능합니다."), + INVALID_DELETE_CONFIRMATION(HttpStatus.BAD_REQUEST, "ROOM_014", "삭제 확인 메시지가 일치하지 않습니다."), + CHAT_DELETE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "ROOM_015", "채팅 삭제 중 오류가 발생했습니다."), // ======================== 스터디 플래너 관련 ======================== PLAN_NOT_FOUND(HttpStatus.NOT_FOUND, "PLAN_001", "존재하지 않는 학습 계획입니다."), @@ -52,6 +53,10 @@ public enum ErrorCode { TODO_NOT_FOUND(HttpStatus.NOT_FOUND, "TODO_001", "존재하지 않는 할 일입니다."), TODO_FORBIDDEN(HttpStatus.FORBIDDEN, "TODO_002", "할 일에 대한 접근 권한이 없습니다."), + // ======================== 알림 관련 ======================== + NOTIFICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "NOTIFICATION_001", "존재하지 않는 알림입니다."), + NOTIFICATION_FORBIDDEN(HttpStatus.FORBIDDEN, "NOTIFICATION_002", "알림에 대한 접근 권한이 없습니다."), + NOTIFICATION_ALREADY_READ(HttpStatus.BAD_REQUEST, "NOTIFICATION_003", "이미 읽은 알림입니다."), // ======================== 메시지 관련 ======================== MESSAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "MESSAGE_001", "존재하지 않는 메시지입니다."), @@ -77,6 +82,13 @@ public enum ErrorCode { WS_INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "WS_015", "WebSocket 내부 오류가 발생했습니다."), WS_CHAT_DELETE_FORBIDDEN(HttpStatus.FORBIDDEN, "WS_016", "채팅 삭제 권한이 없습니다. 방장 또는 부방장만 가능합니다."), + // ======================== 커뮤니티 관련 ======================== + POST_NOT_FOUND(HttpStatus.NOT_FOUND, "POST_001", "존재하지 않는 게시글입니다."), + POST_NO_PERMISSION(HttpStatus.FORBIDDEN, "POST_002", "게시글 작성자만 수정/삭제할 수 있습니다."), + CATEGORY_NOT_FOUND(HttpStatus.NOT_FOUND, "POST_003", "존재하지 않는 카테고리입니다."), + COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "COMMENT_001", "존재하지 않는 댓글입니다."), + COMMENT_NO_PERMISSION(HttpStatus.FORBIDDEN, "COMMENT_002", "댓글 작성자만 수정/삭제할 수 있습니다."), + // ======================== 공통 에러 ======================== BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON_400", "잘못된 요청입니다."), FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON_403", "접근 권한이 없습니다."), diff --git a/src/main/java/com/back/global/exception/GlobalExceptionHandler.java b/src/main/java/com/back/global/exception/GlobalExceptionHandler.java index c5f7e94f..3d23fa6e 100644 --- a/src/main/java/com/back/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/back/global/exception/GlobalExceptionHandler.java @@ -34,8 +34,8 @@ public ResponseEntity> handleValidationException(MethodArgumentNotV .body(RsData.fail(ErrorCode.BAD_REQUEST)); } - // PATH VARIABLE, REQUEST PARAMETER 타입 미스매치 예외 처리 - // 클라이언트의 데이터 형식이 서버 인자 타입과 안 맞는 경우 예외 (형식 불일치) + // PATH VARIABLE, REQUEST PARAMETER의 validation 예외 처리 + // 클라이언트의 데이터 형식이 서버 인자 형식과 안 맞는 경우 예외 (형식 불일치) @ExceptionHandler(MethodArgumentTypeMismatchException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) public ResponseEntity> handleMethodArgumentTypeMismatch(MethodArgumentTypeMismatchException ex) { diff --git a/src/main/java/com/back/global/security/SecurityConfig.java b/src/main/java/com/back/global/security/SecurityConfig.java index 64478985..b8fa34e9 100644 --- a/src/main/java/com/back/global/security/SecurityConfig.java +++ b/src/main/java/com/back/global/security/SecurityConfig.java @@ -40,6 +40,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() // CORS Preflight 요청 허용 .requestMatchers("/api/auth/**", "/oauth2/**", "/login/oauth2/**").permitAll() .requestMatchers("api/ws/**", "/ws/**").permitAll() + .requestMatchers(HttpMethod.GET, "/api/posts/**").permitAll() .requestMatchers("/api/rooms/*/messages/**").permitAll() //스터디 룸 내에 잡혀있어 있는 채팅 관련 전체 허용 //.requestMatchers("/api/rooms/RoomChatApiControllerTest").permitAll() // 테스트용 임시 허용 .requestMatchers("/","/swagger-ui/**", "/v3/api-docs/**").permitAll() // Swagger 허용 diff --git a/src/main/java/com/back/global/config/WebSocketConfig.java b/src/main/java/com/back/global/websocket/config/WebSocketConfig.java similarity index 99% rename from src/main/java/com/back/global/config/WebSocketConfig.java rename to src/main/java/com/back/global/websocket/config/WebSocketConfig.java index d0c2e13d..ba2ba7f3 100644 --- a/src/main/java/com/back/global/config/WebSocketConfig.java +++ b/src/main/java/com/back/global/websocket/config/WebSocketConfig.java @@ -1,4 +1,4 @@ -package com.back.global.config; +package com.back.global.websocket.config; import com.back.global.security.user.CustomUserDetails; import com.back.global.security.jwt.JwtTokenProvider; diff --git a/src/main/java/com/back/global/websocket/config/WebSocketConstants.java b/src/main/java/com/back/global/websocket/config/WebSocketConstants.java new file mode 100644 index 00000000..102bb1d3 --- /dev/null +++ b/src/main/java/com/back/global/websocket/config/WebSocketConstants.java @@ -0,0 +1,76 @@ +package com.back.global.websocket.config; + +import java.time.Duration; + +public final class WebSocketConstants { + + private WebSocketConstants() { + throw new AssertionError("상수 클래스는 인스턴스화할 수 없습니다."); + } + + // ===== TTL & Timeout 설정 ===== + + /** + * WebSocket 세션 TTL (6분) + * - Heartbeat로 연장됨 + */ + public static final Duration SESSION_TTL = Duration.ofMinutes(6); + + /** + * Heartbeat 권장 간격 (5분) + * - 클라이언트가 이 주기로 Heartbeat 전송 권장 + */ + public static final Duration HEARTBEAT_INTERVAL = Duration.ofMinutes(5); + + // ===== Redis Key 패턴 ===== + + /** + * 사용자 세션 정보 저장 Key + * - 패턴: ws:user:{userId} + * - 값: WebSocketSessionInfo + */ + public static final String USER_SESSION_KEY_PREFIX = "ws:user:"; + + /** + * 세션 → 사용자 매핑 Key + * - 패턴: ws:session:{sessionId} + * - 값: userId (Long) + */ + public static final String SESSION_USER_KEY_PREFIX = "ws:session:"; + + /** + * 방별 참가자 목록 Key + * - 패턴: ws:room:{roomId}:users + * - 값: Set + */ + public static final String ROOM_USERS_KEY_PREFIX = "ws:room:"; + public static final String ROOM_USERS_KEY_SUFFIX = ":users"; + + // ===== Key 빌더 헬퍼 메서드 ===== + + public static String buildUserSessionKey(Long userId) { + return USER_SESSION_KEY_PREFIX + userId; + } + + public static String buildSessionUserKey(String sessionId) { + return SESSION_USER_KEY_PREFIX + sessionId; + } + + public static String buildRoomUsersKey(Long roomId) { + return ROOM_USERS_KEY_PREFIX + roomId + ROOM_USERS_KEY_SUFFIX; + } + + public static String buildUserSessionKeyPattern() { + return USER_SESSION_KEY_PREFIX + "*"; + } + + // ===== API 응답용 ===== + + public static String getSessionTTLDescription() { + return SESSION_TTL.toMinutes() + "분 (Heartbeat 방식)"; + } + + public static String getHeartbeatIntervalDescription() { + return HEARTBEAT_INTERVAL.toMinutes() + "분"; + } +} \ No newline at end of file diff --git a/src/main/java/com/back/global/websocket/controller/WebSocketApiController.java b/src/main/java/com/back/global/websocket/controller/WebSocketApiController.java index e8e08593..7b4b61fe 100644 --- a/src/main/java/com/back/global/websocket/controller/WebSocketApiController.java +++ b/src/main/java/com/back/global/websocket/controller/WebSocketApiController.java @@ -1,6 +1,7 @@ package com.back.global.websocket.controller; import com.back.global.common.dto.RsData; +import com.back.global.websocket.config.WebSocketConstants; import com.back.global.websocket.service.WebSocketSessionManager; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -29,8 +30,8 @@ public ResponseEntity>> healthCheck() { data.put("service", "WebSocket"); data.put("status", "running"); data.put("timestamp", LocalDateTime.now()); - data.put("sessionTTL", "10분 (Heartbeat 방식)"); - data.put("heartbeatInterval", "5분"); + data.put("sessionTTL", WebSocketConstants.getSessionTTLDescription()); + data.put("heartbeatInterval", WebSocketConstants.getHeartbeatIntervalDescription()); data.put("totalOnlineUsers", sessionManager.getTotalOnlineUserCount()); data.put("endpoints", Map.of( "websocket", "/ws", @@ -53,8 +54,8 @@ public ResponseEntity>> getConnectionInfo() { connectionInfo.put("websocketUrl", "/ws"); connectionInfo.put("sockjsSupport", true); connectionInfo.put("stompVersion", "1.2"); - connectionInfo.put("heartbeatInterval", "5분"); - connectionInfo.put("sessionTTL", "10분"); + connectionInfo.put("heartbeatInterval", WebSocketConstants.getHeartbeatIntervalDescription()); + connectionInfo.put("sessionTTL", WebSocketConstants.getSessionTTLDescription()); connectionInfo.put("subscribeTopics", Map.of( "roomChat", "/topic/rooms/{roomId}/chat", "privateMessage", "/user/queue/messages", diff --git a/src/main/java/com/back/global/websocket/service/RoomParticipantService.java b/src/main/java/com/back/global/websocket/service/RoomParticipantService.java new file mode 100644 index 00000000..89f8bd1c --- /dev/null +++ b/src/main/java/com/back/global/websocket/service/RoomParticipantService.java @@ -0,0 +1,100 @@ +package com.back.global.websocket.service; + +import com.back.global.exception.CustomException; +import com.back.global.exception.ErrorCode; +import com.back.global.websocket.dto.WebSocketSessionInfo; +import com.back.global.websocket.store.RedisSessionStore; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.Set; + +/** + * 방 참가자 관리 서비스 + * - 방 입장/퇴장 처리 + * - 방별 참가자 목록 관리 + * - 방별 온라인 사용자 통계 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class RoomParticipantService { + + private final RedisSessionStore redisSessionStore; + + // 사용자 방 입장 + public void enterRoom(Long userId, Long roomId) { + WebSocketSessionInfo sessionInfo = redisSessionStore.getUserSession(userId); + + if (sessionInfo == null) { + log.warn("세션 정보가 없어 방 입장 실패 - 사용자: {}, 방: {}", userId, roomId); + throw new CustomException(ErrorCode.WS_SESSION_NOT_FOUND); + } + + if (sessionInfo.currentRoomId() != null) { + exitRoom(userId, sessionInfo.currentRoomId()); + log.debug("기존 방에서 퇴장 처리 완료 - 사용자: {}, 이전 방: {}", + userId, sessionInfo.currentRoomId()); + } + + WebSocketSessionInfo updatedSession = sessionInfo.withRoomId(roomId); + redisSessionStore.saveUserSession(userId, updatedSession); + redisSessionStore.addUserToRoom(roomId, userId); + + log.info("방 입장 완료 - 사용자: {}, 방: {}", userId, roomId); + } + + // 사용자 방 퇴장 + public void exitRoom(Long userId, Long roomId) { + WebSocketSessionInfo sessionInfo = redisSessionStore.getUserSession(userId); + + if (sessionInfo == null) { + log.warn("세션 정보가 없지만 방 퇴장 처리 계속 진행 - 사용자: {}, 방: {}", userId, roomId); + } else { + WebSocketSessionInfo updatedSession = sessionInfo.withoutRoom(); + redisSessionStore.saveUserSession(userId, updatedSession); + } + + redisSessionStore.removeUserFromRoom(roomId, userId); + log.info("방 퇴장 완료 - 사용자: {}, 방: {}", userId, roomId); + } + + // 사용자의 현재 방 ID 조회 + public Long getCurrentRoomId(Long userId) { + WebSocketSessionInfo sessionInfo = redisSessionStore.getUserSession(userId); + return sessionInfo != null ? sessionInfo.currentRoomId() : null; + } + + // 방의 온라인 참가자 목록 조회 + public Set getParticipants(Long roomId) { + return redisSessionStore.getRoomUsers(roomId); + } + + // 방의 온라인 참가자 수 조회 + public long getParticipantCount(Long roomId) { + return redisSessionStore.getRoomUserCount(roomId); + } + + // 사용자가 특정 방에 참여 중인지 확인 + public boolean isUserInRoom(Long userId, Long roomId) { + Long currentRoomId = getCurrentRoomId(userId); + return currentRoomId != null && currentRoomId.equals(roomId); + } + + // 모든 방에서 사용자 퇴장 처리 (세션 종료 시 사용) + public void exitAllRooms(Long userId) { + try { + Long currentRoomId = getCurrentRoomId(userId); + + if (currentRoomId != null) { + exitRoom(userId, currentRoomId); + log.info("모든 방에서 퇴장 처리 완료 - 사용자: {}", userId); + } + + } catch (Exception e) { + log.error("모든 방 퇴장 처리 실패 - 사용자: {}", userId, e); + // 에러를 던지지 않고 로그만 남김 (세션 종료는 계속 진행되어야 함) + } + } +} diff --git a/src/main/java/com/back/global/websocket/service/UserSessionService.java b/src/main/java/com/back/global/websocket/service/UserSessionService.java new file mode 100644 index 00000000..bb45a051 --- /dev/null +++ b/src/main/java/com/back/global/websocket/service/UserSessionService.java @@ -0,0 +1,99 @@ +package com.back.global.websocket.service; + +import com.back.global.exception.CustomException; +import com.back.global.exception.ErrorCode; +import com.back.global.websocket.dto.WebSocketSessionInfo; +import com.back.global.websocket.store.RedisSessionStore; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/** + * 사용자 세션 관리 서비스 + * - 세션 생명주기 관리 (등록, 종료) + * - Heartbeat 처리 + * - 중복 연결 방지 + * - 연결 상태 조회 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class UserSessionService { + + private final RedisSessionStore redisSessionStore; + + // 세션 등록 + public void registerSession(Long userId, String sessionId) { + WebSocketSessionInfo existingSession = redisSessionStore.getUserSession(userId); + if (existingSession != null) { + terminateSession(existingSession.sessionId()); + log.info("기존 세션 제거 후 새 세션 등록 - 사용자: {}", userId); + } + + WebSocketSessionInfo newSession = WebSocketSessionInfo.createNewSession(userId, sessionId); + redisSessionStore.saveUserSession(userId, newSession); + redisSessionStore.saveSessionUserMapping(sessionId, userId); + + log.info("WebSocket 세션 등록 완료 - 사용자: {}, 세션: {}", userId, sessionId); + } + + // 세션 종료 + public void terminateSession(String sessionId) { + Long userId = redisSessionStore.getUserIdBySession(sessionId); + + if (userId != null) { + redisSessionStore.deleteUserSession(userId); + redisSessionStore.deleteSessionUserMapping(sessionId); + log.info("WebSocket 세션 종료 완료 - 세션: {}, 사용자: {}", sessionId, userId); + } else { + log.warn("종료할 세션을 찾을 수 없음 - 세션: {}", sessionId); + } + } + + // Heartbeat 처리 (활동 시간 업데이트 및 TTL 연장) + public void processHeartbeat(Long userId) { + WebSocketSessionInfo sessionInfo = redisSessionStore.getUserSession(userId); + + if (sessionInfo == null) { + log.warn("세션 정보가 없어 Heartbeat 처리 실패 - 사용자: {}", userId); + return; + } + + WebSocketSessionInfo updatedSession = sessionInfo.withUpdatedActivity(); + redisSessionStore.saveUserSession(userId, updatedSession); + + log.debug("Heartbeat 처리 완료 - 사용자: {}, TTL 연장", userId); + } + + // 사용자 연결 상태 확인 + public boolean isConnected(Long userId) { + return redisSessionStore.existsUserSession(userId); + } + + // 사용자 세션 정보 조회 + public WebSocketSessionInfo getSessionInfo(Long userId) { + WebSocketSessionInfo sessionInfo = redisSessionStore.getUserSession(userId); + + if (sessionInfo == null) { + log.debug("세션 정보 없음 - 사용자: {}", userId); + } + + return sessionInfo; + } + + // 세션ID로 사용자ID 조회 + public Long getUserIdBySessionId(String sessionId) { + return redisSessionStore.getUserIdBySession(sessionId); + } + + // 사용자의 현재 방 ID 조회 + public Long getCurrentRoomId(Long userId) { + WebSocketSessionInfo sessionInfo = redisSessionStore.getUserSession(userId); + return sessionInfo != null ? sessionInfo.currentRoomId() : null; + } + + // 전체 온라인 사용자 수 조회 + public long getTotalOnlineUserCount() { + return redisSessionStore.getTotalOnlineUserCount(); + } +} \ No newline at end of file diff --git a/src/main/java/com/back/global/websocket/service/WebSocketSessionManager.java b/src/main/java/com/back/global/websocket/service/WebSocketSessionManager.java index 3e770c04..92cd4818 100644 --- a/src/main/java/com/back/global/websocket/service/WebSocketSessionManager.java +++ b/src/main/java/com/back/global/websocket/service/WebSocketSessionManager.java @@ -1,276 +1,96 @@ package com.back.global.websocket.service; -import com.back.global.exception.CustomException; -import com.back.global.exception.ErrorCode; import com.back.global.websocket.dto.WebSocketSessionInfo; -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; -import java.time.Duration; -import java.util.LinkedHashMap; import java.util.Set; -import java.util.stream.Collectors; @Slf4j @Service @RequiredArgsConstructor public class WebSocketSessionManager { - private final RedisTemplate redisTemplate; + private final UserSessionService userSessionService; + private final RoomParticipantService roomParticipantService; - // Redis Key 패턴 - private static final String USER_SESSION_KEY = "ws:user:{}"; - private static final String SESSION_USER_KEY = "ws:session:{}"; - private static final String ROOM_USERS_KEY = "ws:room:{}:users"; - - // TTL 설정 - private static final int SESSION_TTL_MINUTES = 6; - - // 사용자 세션 추가 (연결 시 호출) + // 사용자 세션 추가 (WebSocket 연결 시 호출) public void addSession(Long userId, String sessionId) { - try { - WebSocketSessionInfo sessionInfo = WebSocketSessionInfo.createNewSession(userId, sessionId); - - String userKey = USER_SESSION_KEY.replace("{}", userId.toString()); - String sessionKey = SESSION_USER_KEY.replace("{}", sessionId); - - // 기존 세션이 있다면 제거 (중복 연결 방지) - WebSocketSessionInfo existingSession = getSessionInfo(userId); - if (existingSession != null) { - removeSessionInternal(existingSession.sessionId()); - log.info("기존 세션 제거 후 새 세션 등록 - 사용자: {}", userId); - } + userSessionService.registerSession(userId, sessionId); + } - // 새 세션 등록 (TTL 10분) - redisTemplate.opsForValue().set(userKey, sessionInfo, Duration.ofMinutes(SESSION_TTL_MINUTES)); - redisTemplate.opsForValue().set(sessionKey, userId, Duration.ofMinutes(SESSION_TTL_MINUTES)); + // 세션 제거 (WebSocket 연결 종료 시 호출) + public void removeSession(String sessionId) { + Long userId = userSessionService.getUserIdBySessionId(sessionId); - log.info("WebSocket 세션 등록 완료 - 사용자: {}, 세션: {}, TTL: {}분", - userId, sessionId, SESSION_TTL_MINUTES); + if (userId != null) { + // 1. 모든 방에서 퇴장 + roomParticipantService.exitAllRooms(userId); - } catch (Exception e) { - log.error("WebSocket 세션 등록 실패 - 사용자: {}", userId, e); - throw new CustomException(ErrorCode.WS_CONNECTION_FAILED); + // 2. 세션 종료 + userSessionService.terminateSession(sessionId); + } else { + log.warn("종료할 세션을 찾을 수 없음 - 세션: {}", sessionId); } } // 사용자 연결 상태 확인 public boolean isUserConnected(Long userId) { - try { - String userKey = USER_SESSION_KEY.replace("{}", userId.toString()); - return Boolean.TRUE.equals(redisTemplate.hasKey(userKey)); - } catch (Exception e) { - log.error("사용자 연결 상태 확인 실패 - 사용자: {}", userId, e); - throw new CustomException(ErrorCode.WS_REDIS_ERROR); - } + return userSessionService.isConnected(userId); } // 사용자 세션 정보 조회 public WebSocketSessionInfo getSessionInfo(Long userId) { - try { - String userKey = USER_SESSION_KEY.replace("{}", userId.toString()); - Object value = redisTemplate.opsForValue().get(userKey); - - if (value == null) { - return null; - } - - // LinkedHashMap으로 역직렬화된 경우 또는 타입이 맞지 않는 경우 변환 - if (value instanceof LinkedHashMap || !(value instanceof WebSocketSessionInfo)) { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new JavaTimeModule()); - mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); - return mapper.convertValue(value, WebSocketSessionInfo.class); - } - - return (WebSocketSessionInfo) value; - - } catch (Exception e) { - log.error("세션 정보 조회 실패 - 사용자: {}", userId, e); - throw new CustomException(ErrorCode.WS_REDIS_ERROR); - } - } - - // 세션 제거 (연결 종료 시 호출) - public void removeSession(String sessionId) { - try { - removeSessionInternal(sessionId); - log.info("WebSocket 세션 제거 완료 - 세션: {}", sessionId); - } catch (Exception e) { - log.error("WebSocket 세션 제거 실패 - 세션: {}", sessionId, e); - throw new CustomException(ErrorCode.WS_REDIS_ERROR); - } + return userSessionService.getSessionInfo(userId); } - // 사용자 활동 시간 업데이트 및 TTL 연장 (Heartbeat 시 호출) + // Heartbeat 처리 (활동 시간 업데이트 및 TTL 연장) public void updateLastActivity(Long userId) { - try { - WebSocketSessionInfo sessionInfo = getSessionInfo(userId); - if (sessionInfo != null) { - // 마지막 활동 시간 업데이트 - WebSocketSessionInfo updatedSessionInfo = sessionInfo.withUpdatedActivity(); - - String userKey = USER_SESSION_KEY.replace("{}", userId.toString()); - - // TTL 10분으로 연장 - redisTemplate.opsForValue().set(userKey, updatedSessionInfo, Duration.ofMinutes(SESSION_TTL_MINUTES)); + userSessionService.processHeartbeat(userId); + } - log.debug("사용자 활동 시간 업데이트 완료 - 사용자: {}, TTL 연장", userId); - } else { - log.warn("세션 정보가 없어 활동 시간 업데이트 실패 - 사용자: {}", userId); - } - } catch (CustomException e) { - throw e; - } catch (Exception e) { - log.error("사용자 활동 시간 업데이트 실패 - 사용자: {}", userId, e); - throw new CustomException(ErrorCode.WS_ACTIVITY_UPDATE_FAILED); - } + // 전체 온라인 사용자 수 조회 + public long getTotalOnlineUserCount() { + return userSessionService.getTotalOnlineUserCount(); } - // 사용자가 방에 입장 (WebSocket 전용) + // 사용자가 방에 입장 public void joinRoom(Long userId, Long roomId) { - try { - WebSocketSessionInfo sessionInfo = getSessionInfo(userId); - if (sessionInfo != null) { - // 기존 방에서 퇴장 - if (sessionInfo.currentRoomId() != null) { - leaveRoom(userId, sessionInfo.currentRoomId()); - } - - // 새 방 정보 업데이트 - WebSocketSessionInfo updatedSessionInfo = sessionInfo.withRoomId(roomId); - - String userKey = USER_SESSION_KEY.replace("{}", userId.toString()); - redisTemplate.opsForValue().set(userKey, updatedSessionInfo, Duration.ofMinutes(SESSION_TTL_MINUTES)); - - // 방 참여자 목록에 추가 - String roomUsersKey = ROOM_USERS_KEY.replace("{}", roomId.toString()); - redisTemplate.opsForSet().add(roomUsersKey, userId); - redisTemplate.expire(roomUsersKey, Duration.ofMinutes(SESSION_TTL_MINUTES)); - - log.info("WebSocket 방 입장 완료 - 사용자: {}, 방: {}", userId, roomId); - } else { - log.warn("세션 정보가 없어 방 입장 처리 실패 - 사용자: {}, 방: {}", userId, roomId); - } - } catch (CustomException e) { - throw e; - } catch (Exception e) { - log.error("사용자 방 입장 실패 - 사용자: {}, 방: {}", userId, roomId, e); - throw new CustomException(ErrorCode.WS_ROOM_JOIN_FAILED); - } + roomParticipantService.enterRoom(userId, roomId); } - // 사용자가 방에서 퇴장 (WebSocket 전용) + // 사용자가 방에서 퇴장 public void leaveRoom(Long userId, Long roomId) { - try { - WebSocketSessionInfo sessionInfo = getSessionInfo(userId); - if (sessionInfo != null) { - // 방 정보 제거 - WebSocketSessionInfo updatedSessionInfo = sessionInfo.withoutRoom(); - - String userKey = USER_SESSION_KEY.replace("{}", userId.toString()); - redisTemplate.opsForValue().set(userKey, updatedSessionInfo, Duration.ofMinutes(SESSION_TTL_MINUTES)); - - // 방 참여자 목록에서 제거 - String roomUsersKey = ROOM_USERS_KEY.replace("{}", roomId.toString()); - redisTemplate.opsForSet().remove(roomUsersKey, userId); - - log.info("WebSocket 방 퇴장 완료 - 사용자: {}, 방: {}", userId, roomId); - } - } catch (CustomException e) { - throw e; - } catch (Exception e) { - log.error("사용자 방 퇴장 실패 - 사용자: {}, 방: {}", userId, roomId, e); - throw new CustomException(ErrorCode.WS_ROOM_LEAVE_FAILED); - } + roomParticipantService.exitRoom(userId, roomId); } // 방의 온라인 사용자 수 조회 public long getRoomOnlineUserCount(Long roomId) { - try { - String roomUsersKey = ROOM_USERS_KEY.replace("{}", roomId.toString()); - Long count = redisTemplate.opsForSet().size(roomUsersKey); - return count != null ? count : 0; - } catch (Exception e) { - log.error("방 온라인 사용자 수 조회 실패 - 방: {}", roomId, e); - throw new CustomException(ErrorCode.WS_REDIS_ERROR); - } + return roomParticipantService.getParticipantCount(roomId); } // 방의 온라인 사용자 목록 조회 public Set getOnlineUsersInRoom(Long roomId) { - try { - String roomUsersKey = ROOM_USERS_KEY.replace("{}", roomId.toString()); - Set userIds = redisTemplate.opsForSet().members(roomUsersKey); - - if (userIds != null) { - return userIds.stream() - .map(this::convertToLong) // 안전한 변환 - .collect(Collectors.toSet()); - } - return Set.of(); - } catch (Exception e) { - log.error("방 온라인 사용자 목록 조회 실패 - 방: {}", roomId, e); - throw new CustomException(ErrorCode.WS_REDIS_ERROR); - } - } - - // 전체 온라인 사용자 수 조회 - public long getTotalOnlineUserCount() { - try { - Set userKeys = redisTemplate.keys(USER_SESSION_KEY.replace("{}", "*")); - return userKeys != null ? userKeys.size() : 0; - } catch (Exception e) { - log.error("전체 온라인 사용자 수 조회 실패", e); - return 0; - } + return roomParticipantService.getParticipants(roomId); } // 특정 사용자의 현재 방 조회 public Long getUserCurrentRoomId(Long userId) { - try { - WebSocketSessionInfo sessionInfo = getSessionInfo(userId); - return sessionInfo != null ? sessionInfo.currentRoomId() : null; - } catch (CustomException e) { - log.error("사용자 현재 방 조회 실패 - 사용자: {}", userId, e); - return null; - } + return roomParticipantService.getCurrentRoomId(userId); } - // 내부적으로 세션 제거 처리 - private void removeSessionInternal(String sessionId) { - String sessionKey = SESSION_USER_KEY.replace("{}", sessionId); - Object userIdObj = redisTemplate.opsForValue().get(sessionKey); - - if (userIdObj != null) { - Long userId = convertToLong(userIdObj); // 안전한 변환 - WebSocketSessionInfo sessionInfo = getSessionInfo(userId); - - // 방에서 퇴장 처리 - if (sessionInfo != null && sessionInfo.currentRoomId() != null) { - leaveRoom(userId, sessionInfo.currentRoomId()); - } - - // 세션 데이터 삭제 - String userKey = USER_SESSION_KEY.replace("{}", userId.toString()); - redisTemplate.delete(userKey); - redisTemplate.delete(sessionKey); - } + // 사용자가 특정 방에 참여 중인지 확인 + public boolean isUserInRoom(Long userId, Long roomId) { + return roomParticipantService.isUserInRoom(userId, roomId); } - // Object를 Long으로 안전하게 변환하는 헬퍼 메서드 - private Long convertToLong(Object obj) { - if (obj instanceof Long) { - return (Long) obj; - } else if (obj instanceof Number) { - return ((Number) obj).longValue(); - } else { - throw new IllegalArgumentException("Cannot convert " + obj.getClass() + " to Long"); - } + // 여러 방의 온라인 사용자 수 일괄 조회 (N+1 방지) + public java.util.Map getBulkRoomOnlineUserCounts(java.util.List roomIds) { + return roomIds.stream() + .collect(java.util.stream.Collectors.toMap( + roomId -> roomId, + this::getRoomOnlineUserCount + )); } } \ No newline at end of file diff --git a/src/main/java/com/back/global/websocket/store/RedisSessionStore.java b/src/main/java/com/back/global/websocket/store/RedisSessionStore.java new file mode 100644 index 00000000..6dc7d93e --- /dev/null +++ b/src/main/java/com/back/global/websocket/store/RedisSessionStore.java @@ -0,0 +1,202 @@ +package com.back.global.websocket.store; + +import com.back.global.exception.CustomException; +import com.back.global.exception.ErrorCode; +import com.back.global.websocket.config.WebSocketConstants; +import com.back.global.websocket.dto.WebSocketSessionInfo; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Redis 저장소 계층 + * - Redis CRUD 연산 + * - Key 패턴 관리 + * - TTL 관리 + * - 타입 변환 + */ +@Slf4j +@Component +public class RedisSessionStore { + + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + public RedisSessionStore(RedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + this.objectMapper = new ObjectMapper(); + this.objectMapper.registerModule(new JavaTimeModule()); + this.objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + } + + public void saveUserSession(Long userId, WebSocketSessionInfo sessionInfo) { + try { + String userKey = WebSocketConstants.buildUserSessionKey(userId); + redisTemplate.opsForValue().set(userKey, sessionInfo, WebSocketConstants.SESSION_TTL); + log.debug("사용자 세션 정보 저장 완료 - userId: {}", userId); + } catch (Exception e) { + log.error("사용자 세션 정보 저장 실패 - userId: {}", userId, e); + throw new CustomException(ErrorCode.WS_REDIS_ERROR); + } + } + + public void saveSessionUserMapping(String sessionId, Long userId) { + try { + String sessionKey = WebSocketConstants.buildSessionUserKey(sessionId); + redisTemplate.opsForValue().set(sessionKey, userId, WebSocketConstants.SESSION_TTL); + log.debug("세션-사용자 매핑 저장 완료 - sessionId: {}", sessionId); + } catch (Exception e) { + log.error("세션-사용자 매핑 저장 실패 - sessionId: {}", sessionId, e); + throw new CustomException(ErrorCode.WS_REDIS_ERROR); + } + } + + public WebSocketSessionInfo getUserSession(Long userId) { + try { + String userKey = WebSocketConstants.buildUserSessionKey(userId); + Object value = redisTemplate.opsForValue().get(userKey); + + if (value == null) { + return null; + } + + if (value instanceof LinkedHashMap || !(value instanceof WebSocketSessionInfo)) { + return objectMapper.convertValue(value, WebSocketSessionInfo.class); + } + + return (WebSocketSessionInfo) value; + + } catch (Exception e) { + log.error("사용자 세션 정보 조회 실패 - userId: {}", userId, e); + throw new CustomException(ErrorCode.WS_REDIS_ERROR); + } + } + + public Long getUserIdBySession(String sessionId) { + try { + String sessionKey = WebSocketConstants.buildSessionUserKey(sessionId); + Object value = redisTemplate.opsForValue().get(sessionKey); + + if (value == null) { + return null; + } + + return convertToLong(value); + + } catch (Exception e) { + log.error("세션으로 사용자 조회 실패 - sessionId: {}", sessionId, e); + throw new CustomException(ErrorCode.WS_REDIS_ERROR); + } + } + + public void deleteUserSession(Long userId) { + try { + String userKey = WebSocketConstants.buildUserSessionKey(userId); + redisTemplate.delete(userKey); + log.debug("사용자 세션 정보 삭제 완료 - userId: {}", userId); + } catch (Exception e) { + log.error("사용자 세션 정보 삭제 실패 - userId: {}", userId, e); + throw new CustomException(ErrorCode.WS_REDIS_ERROR); + } + } + + public void deleteSessionUserMapping(String sessionId) { + try { + String sessionKey = WebSocketConstants.buildSessionUserKey(sessionId); + redisTemplate.delete(sessionKey); + log.debug("세션-사용자 매핑 삭제 완료 - sessionId: {}", sessionId); + } catch (Exception e) { + log.error("세션-사용자 매핑 삭제 실패 - sessionId: {}", sessionId, e); + throw new CustomException(ErrorCode.WS_REDIS_ERROR); + } + } + + public boolean existsUserSession(Long userId) { + try { + String userKey = WebSocketConstants.buildUserSessionKey(userId); + return Boolean.TRUE.equals(redisTemplate.hasKey(userKey)); + } catch (Exception e) { + log.error("사용자 세션 존재 여부 확인 실패 - userId: {}", userId, e); + throw new CustomException(ErrorCode.WS_REDIS_ERROR); + } + } + + public void addUserToRoom(Long roomId, Long userId) { + try { + String roomUsersKey = WebSocketConstants.buildRoomUsersKey(roomId); + redisTemplate.opsForSet().add(roomUsersKey, userId); + redisTemplate.expire(roomUsersKey, WebSocketConstants.SESSION_TTL); + log.debug("방에 사용자 추가 완료 - roomId: {}, userId: {}", roomId, userId); + } catch (Exception e) { + log.error("방에 사용자 추가 실패 - roomId: {}, userId: {}", roomId, userId, e); + throw new CustomException(ErrorCode.WS_REDIS_ERROR); + } + } + + public void removeUserFromRoom(Long roomId, Long userId) { + try { + String roomUsersKey = WebSocketConstants.buildRoomUsersKey(roomId); + redisTemplate.opsForSet().remove(roomUsersKey, userId); + log.debug("방에서 사용자 제거 완료 - roomId: {}, userId: {}", roomId, userId); + } catch (Exception e) { + log.error("방에서 사용자 제거 실패 - roomId: {}, userId: {}", roomId, userId, e); + throw new CustomException(ErrorCode.WS_REDIS_ERROR); + } + } + + public Set getRoomUsers(Long roomId) { + try { + String roomUsersKey = WebSocketConstants.buildRoomUsersKey(roomId); + Set userIds = redisTemplate.opsForSet().members(roomUsersKey); + + if (userIds != null) { + return userIds.stream() + .map(this::convertToLong) + .collect(Collectors.toSet()); + } + return Set.of(); + + } catch (Exception e) { + log.error("방 사용자 목록 조회 실패 - roomId: {}", roomId, e); + throw new CustomException(ErrorCode.WS_REDIS_ERROR); + } + } + + public long getRoomUserCount(Long roomId) { + try { + String roomUsersKey = WebSocketConstants.buildRoomUsersKey(roomId); + Long count = redisTemplate.opsForSet().size(roomUsersKey); + return count != null ? count : 0; + } catch (Exception e) { + log.error("방 사용자 수 조회 실패 - roomId: {}", roomId, e); + throw new CustomException(ErrorCode.WS_REDIS_ERROR); + } + } + + public long getTotalOnlineUserCount() { + try { + Set userKeys = redisTemplate.keys(WebSocketConstants.buildUserSessionKeyPattern()); + return userKeys != null ? userKeys.size() : 0; + } catch (Exception e) { + log.error("전체 온라인 사용자 수 조회 실패", e); + return 0; + } + } + + private Long convertToLong(Object obj) { + if (obj instanceof Long) { + return (Long) obj; + } else if (obj instanceof Number) { + return ((Number) obj).longValue(); + } else { + throw new IllegalArgumentException("Cannot convert " + obj.getClass() + " to Long"); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/back/global/websocket/webrtc/service/WebRTCSignalValidator.java b/src/main/java/com/back/global/websocket/webrtc/service/WebRTCSignalValidator.java index 6a8dfd96..e150cdc0 100644 --- a/src/main/java/com/back/global/websocket/webrtc/service/WebRTCSignalValidator.java +++ b/src/main/java/com/back/global/websocket/webrtc/service/WebRTCSignalValidator.java @@ -32,15 +32,17 @@ public void validateSignal(Long roomId, Long fromUserId, Long targetUserId) { } // 2. 발신자가 방에 속해있는지 확인 + // TODO: Redis에서 온라인 상태 확인하도록 변경 Optional fromMember = roomMemberRepository.findByRoomIdAndUserId(roomId, fromUserId); - if (fromMember.isEmpty() || !fromMember.get().isOnline()) { + if (fromMember.isEmpty()) { log.warn("방에 속하지 않은 사용자의 시그널 전송 시도 - roomId: {}, userId: {}", roomId, fromUserId); throw new CustomException(ErrorCode.NOT_ROOM_MEMBER); } // 3. 수신자가 같은 방에 속해있는지 확인 + // TODO: Redis에서 온라인 상태 확인하도록 변경 Optional targetMember = roomMemberRepository.findByRoomIdAndUserId(roomId, targetUserId); - if (targetMember.isEmpty() || !targetMember.get().isOnline()) { + if (targetMember.isEmpty()) { log.warn("수신자가 방에 없거나 오프라인 상태 - roomId: {}, targetUserId: {}", roomId, targetUserId); throw new CustomException(ErrorCode.NOT_ROOM_MEMBER); } @@ -50,9 +52,10 @@ public void validateSignal(Long roomId, Long fromUserId, Long targetUserId) { // 미디어 상태 변경 검증 public void validateMediaStateChange(Long roomId, Long userId) { + // TODO: Redis에서 온라인 상태 확인하도록 변경 Optional member = roomMemberRepository.findByRoomIdAndUserId(roomId, userId); - if (member.isEmpty() || !member.get().isOnline()) { + if (member.isEmpty()) { log.warn("방에 속하지 않은 사용자의 미디어 상태 변경 시도 - roomId: {}, userId: {}", roomId, userId); throw new CustomException(ErrorCode.NOT_ROOM_MEMBER); } diff --git a/src/test/java/com/back/domain/board/controller/CommentControllerTest.java b/src/test/java/com/back/domain/board/controller/CommentControllerTest.java new file mode 100644 index 00000000..ac0c13c3 --- /dev/null +++ b/src/test/java/com/back/domain/board/controller/CommentControllerTest.java @@ -0,0 +1,584 @@ +package com.back.domain.board.controller; + +import com.back.domain.board.dto.CommentRequest; +import com.back.domain.board.entity.Comment; +import com.back.domain.board.entity.Post; +import com.back.domain.board.repository.CommentRepository; +import com.back.domain.board.repository.PostRepository; +import com.back.domain.user.entity.User; +import com.back.domain.user.entity.UserProfile; +import com.back.domain.user.entity.UserStatus; +import com.back.domain.user.repository.UserRepository; +import com.back.fixture.TestJwtTokenProvider; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +class CommentControllerTest { + + @Autowired + private MockMvc mvc; + + @Autowired + private UserRepository userRepository; + + @Autowired + private PostRepository postRepository; + + @Autowired + private CommentRepository commentRepository; + + @Autowired + private TestJwtTokenProvider testJwtTokenProvider; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private ObjectMapper objectMapper; + + private String generateAccessToken(User user) { + return testJwtTokenProvider.createAccessToken( + user.getId(), + user.getUsername(), + user.getRole().name() + ); + } + + // ====================== 댓글 생성 테스트 ====================== + + @Test + @DisplayName("댓글 생성 성공 → 201 Created") + void createComment_success() throws Exception { + // given: 정상 유저 + 게시글 + User user = User.createUser("writer", "writer@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "홍길동", null, "소개글", LocalDate.of(2000, 1, 1), 1000)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + Post post = new Post(user, "첫 글", "내용"); + postRepository.save(post); + + String accessToken = generateAccessToken(user); + + CommentRequest request = new CommentRequest("좋은 글 감사합니다!"); + + // when + ResultActions resultActions = mvc.perform( + post("/api/posts/{postId}/comments", post.getId()) + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ).andDo(print()); + + // then + resultActions + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.code").value("SUCCESS_200")) + .andExpect(jsonPath("$.data.content").value("좋은 글 감사합니다!")) + .andExpect(jsonPath("$.data.author.nickname").value("홍길동")) + .andExpect(jsonPath("$.data.postId").value(post.getId())); + } + + @Test + @DisplayName("댓글 생성 실패 - 존재하지 않는 사용자 → 404 Not Found") + void createComment_userNotFound() throws Exception { + // given: 게시글 저장 + User user = User.createUser("temp", "temp@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "임시", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + Post post = new Post(user, "제목", "내용"); + postRepository.save(post); + + // DB에 없는 userId 기반 토큰 + String fakeToken = testJwtTokenProvider.createAccessToken(999L, "ghost", "USER"); + + CommentRequest request = new CommentRequest("댓글 내용"); + + // when & then + mvc.perform(post("/api/posts/{postId}/comments", post.getId()) + .header("Authorization", "Bearer " + fakeToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("USER_001")) + .andExpect(jsonPath("$.message").value("존재하지 않는 사용자입니다.")); + } + + @Test + @DisplayName("댓글 생성 실패 - 존재하지 않는 게시글 → 404 Not Found") + void createComment_postNotFound() throws Exception { + // given: 정상 유저 + User user = User.createUser("writer2", "writer2@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "작성자", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + String accessToken = generateAccessToken(user); + + CommentRequest request = new CommentRequest("댓글 내용"); + + // when & then + mvc.perform(post("/api/posts/{postId}/comments", 999L) + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("POST_001")) + .andExpect(jsonPath("$.message").value("존재하지 않는 게시글입니다.")); + } + + @Test + @DisplayName("댓글 생성 실패 - 잘못된 요청(필드 누락) → 400 Bad Request") + void createComment_badRequest() throws Exception { + // given: 정상 유저 + 게시글 + User user = User.createUser("writer", "writer@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "홍길동", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + Post post = new Post(user, "제목", "내용"); + postRepository.save(post); + + String accessToken = generateAccessToken(user); + + // content 누락 + String invalidJson = """ + { + } + """; + + // when & then + mvc.perform(post("/api/posts/{postId}/comments", post.getId()) + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(invalidJson)) + .andDo(print()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("COMMON_400")) + .andExpect(jsonPath("$.message").value("잘못된 요청입니다.")); + } + + @Test + @DisplayName("댓글 생성 실패 - 토큰 없음 → 401 Unauthorized") + void createComment_noToken() throws Exception { + // given: 게시글 + User user = User.createUser("writer", "writer@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "작성자", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + Post post = new Post(user, "제목", "내용"); + postRepository.save(post); + + CommentRequest request = new CommentRequest("댓글 내용"); + + // when & then + mvc.perform(post("/api/posts/{postId}/comments", post.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("AUTH_001")) + .andExpect(jsonPath("$.message").value("인증이 필요합니다.")); + } + + // ====================== 댓글 조회 테스트 ====================== + + @Test + @DisplayName("댓글 목록 조회 성공 → 200 OK") + void getComments_success() throws Exception { + // given + User user = User.createUser("writer", "writer@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "홍길동", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + Post post = new Post(user, "제목", "내용"); + postRepository.save(post); + + // 부모 댓글 + Comment parent = new Comment(post, user, "부모 댓글", null); + commentRepository.save(parent); + + // 자식 댓글 + Comment child = new Comment(post, user, "자식 댓글", parent); + commentRepository.save(child); + + String accessToken = generateAccessToken(user); + + // when & then + mvc.perform(get("/api/posts/{postId}/comments", post.getId()) + .header("Authorization", "Bearer " + accessToken) + .param("page", "0") + .param("size", "10")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.code").value("SUCCESS_200")) + .andExpect(jsonPath("$.data.items[0].content").value("부모 댓글")) + .andExpect(jsonPath("$.data.items[0].children[0].content").value("자식 댓글")); + } + + @Test + @DisplayName("댓글 목록 조회 실패 - 존재하지 않는 게시글 → 404 Not Found") + void getComments_postNotFound() throws Exception { + // given + User user = User.createUser("ghost", "ghost@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "유저", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + String accessToken = generateAccessToken(user); + + // when & then + mvc.perform(get("/api/posts/{postId}/comments", 999L) + .header("Authorization", "Bearer " + accessToken) + .param("page", "0") + .param("size", "10")) + .andDo(print()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("POST_001")) + .andExpect(jsonPath("$.message").value("존재하지 않는 게시글입니다.")); + } + + // ====================== 댓글 수정 테스트 ====================== + + @Test + @DisplayName("댓글 수정 성공 → 200 OK") + void updateComment_success() throws Exception { + // given: 유저 + 게시글 + 댓글 + User user = User.createUser("writer", "writer@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "홍길동", null, "소개글", LocalDate.of(2000, 1, 1), 1000)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + Post post = new Post(user, "제목", "내용"); + postRepository.save(post); + + Comment comment = new Comment(post, user, "원래 댓글 내용"); + commentRepository.save(comment); + + String accessToken = generateAccessToken(user); + + CommentRequest updateRequest = new CommentRequest("수정된 댓글 내용입니다."); + + // when + mvc.perform(put("/api/posts/{postId}/comments/{commentId}", post.getId(), comment.getId()) + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest))) + .andDo(print()) + // then + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.code").value("SUCCESS_200")) + .andExpect(jsonPath("$.data.commentId").value(comment.getId())) + .andExpect(jsonPath("$.data.content").value("수정된 댓글 내용입니다.")) + .andExpect(jsonPath("$.data.author.nickname").value("홍길동")); + } + + @Test + @DisplayName("댓글 수정 실패 - 존재하지 않는 게시글 → 404 Not Found") + void updateComment_postNotFound() throws Exception { + // given + User user = User.createUser("writer2", "writer2@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "작성자", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + Post post = new Post(user, "제목", "내용"); + postRepository.save(post); + + Comment comment = new Comment(post, user, "댓글"); + commentRepository.save(comment); + + String accessToken = generateAccessToken(user); + CommentRequest updateRequest = new CommentRequest("수정된 댓글"); + + // when & then + mvc.perform(put("/api/posts/{postId}/comments/{commentId}", 999L, comment.getId()) + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest))) + .andDo(print()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("POST_001")) + .andExpect(jsonPath("$.message").value("존재하지 않는 게시글입니다.")); + } + + @Test + @DisplayName("댓글 수정 실패 - 존재하지 않는 댓글 → 404 Not Found") + void updateComment_commentNotFound() throws Exception { + // given + User user = User.createUser("writer3", "writer3@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "작성자", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + Post post = new Post(user, "제목", "내용"); + postRepository.save(post); + + String accessToken = generateAccessToken(user); + CommentRequest updateRequest = new CommentRequest("수정된 댓글"); + + // when & then + mvc.perform(put("/api/posts/{postId}/comments/{commentId}", post.getId(), 999L) + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest))) + .andDo(print()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("COMMENT_001")) + .andExpect(jsonPath("$.message").value("존재하지 않는 댓글입니다.")); + } + + @Test + @DisplayName("댓글 수정 실패 - 작성자가 아님 → 403 Forbidden") + void updateComment_noPermission() throws Exception { + // given: 작성자와 다른 유저 + User writer = User.createUser("writer", "writer@example.com", passwordEncoder.encode("P@ssw0rd!")); + writer.setUserProfile(new UserProfile(writer, "작성자", null, null, null, 0)); + writer.setUserStatus(UserStatus.ACTIVE); + userRepository.save(writer); + + User other = User.createUser("other", "other@example.com", passwordEncoder.encode("P@ssw0rd!")); + other.setUserProfile(new UserProfile(other, "다른사람", null, null, null, 0)); + other.setUserStatus(UserStatus.ACTIVE); + userRepository.save(other); + + Post post = new Post(writer, "제목", "내용"); + postRepository.save(post); + + Comment comment = new Comment(post, writer, "원래 댓글"); + commentRepository.save(comment); + + String accessToken = generateAccessToken(other); + CommentRequest updateRequest = new CommentRequest("수정된 댓글"); + + // when & then + mvc.perform(put("/api/posts/{postId}/comments/{commentId}", post.getId(), comment.getId()) + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest))) + .andDo(print()) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("COMMENT_002")) + .andExpect(jsonPath("$.message").value("댓글 작성자만 수정/삭제할 수 있습니다.")); + } + + @Test + @DisplayName("댓글 수정 실패 - 잘못된 요청(필드 누락) → 400 Bad Request") + void updateComment_badRequest() throws Exception { + // given + User user = User.createUser("writer4", "writer4@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "작성자4", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + Post post = new Post(user, "제목", "내용"); + postRepository.save(post); + + Comment comment = new Comment(post, user, "댓글"); + commentRepository.save(comment); + + String accessToken = generateAccessToken(user); + + String invalidJson = """ + {} + """; + + // when & then + mvc.perform(put("/api/posts/{postId}/comments/{commentId}", post.getId(), comment.getId()) + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(invalidJson)) + .andDo(print()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("COMMON_400")) + .andExpect(jsonPath("$.message").value("잘못된 요청입니다.")); + } + + @Test + @DisplayName("댓글 수정 실패 - 토큰 없음 → 401 Unauthorized") + void updateComment_noToken() throws Exception { + // given + User user = User.createUser("writer5", "writer5@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "작성자5", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + Post post = new Post(user, "제목", "내용"); + postRepository.save(post); + + Comment comment = new Comment(post, user, "댓글"); + commentRepository.save(comment); + + CommentRequest updateRequest = new CommentRequest("수정된 댓글"); + + // when & then + mvc.perform(put("/api/posts/{postId}/comments/{commentId}", post.getId(), comment.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest))) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("AUTH_001")) + .andExpect(jsonPath("$.message").value("인증이 필요합니다.")); + } + + // ====================== 댓글 삭제 테스트 ====================== + + @Test + @DisplayName("댓글 삭제 성공 → 200 OK") + void deleteComment_success() throws Exception { + // given + User user = User.createUser("writer", "writer@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "홍길동", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + Post post = new Post(user, "제목", "내용"); + postRepository.save(post); + + Comment comment = new Comment(post, user, "삭제할 댓글"); + commentRepository.save(comment); + + String accessToken = generateAccessToken(user); + + // when & then + mvc.perform(delete("/api/posts/{postId}/comments/{commentId}", post.getId(), comment.getId()) + .header("Authorization", "Bearer " + accessToken)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.code").value("SUCCESS_200")) + .andExpect(jsonPath("$.message").value("댓글이 삭제되었습니다.")); + } + + @Test + @DisplayName("댓글 삭제 실패 - 존재하지 않는 게시글 → 404 Not Found") + void deleteComment_postNotFound() throws Exception { + // given + User user = User.createUser("writer2", "writer2@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "작성자2", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + Post post = new Post(user, "제목", "내용"); + postRepository.save(post); + + Comment comment = new Comment(post, user, "댓글"); + commentRepository.save(comment); + + String accessToken = generateAccessToken(user); + + // when & then + mvc.perform(delete("/api/posts/{postId}/comments/{commentId}", 999L, comment.getId()) + .header("Authorization", "Bearer " + accessToken)) + .andDo(print()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("POST_001")) + .andExpect(jsonPath("$.message").value("존재하지 않는 게시글입니다.")); + } + + @Test + @DisplayName("댓글 삭제 실패 - 존재하지 않는 댓글 → 404 Not Found") + void deleteComment_commentNotFound() throws Exception { + // given + User user = User.createUser("writer3", "writer3@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "작성자3", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + Post post = new Post(user, "제목", "내용"); + postRepository.save(post); + + String accessToken = generateAccessToken(user); + + // when & then + mvc.perform(delete("/api/posts/{postId}/comments/{commentId}", post.getId(), 999L) + .header("Authorization", "Bearer " + accessToken)) + .andDo(print()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("COMMENT_001")) + .andExpect(jsonPath("$.message").value("존재하지 않는 댓글입니다.")); + } + + @Test + @DisplayName("댓글 삭제 실패 - 작성자가 아님 → 403 Forbidden") + void deleteComment_noPermission() throws Exception { + // given + User writer = User.createUser("writer", "writer@example.com", passwordEncoder.encode("P@ssw0rd!")); + writer.setUserProfile(new UserProfile(writer, "작성자", null, null, null, 0)); + writer.setUserStatus(UserStatus.ACTIVE); + userRepository.save(writer); + + User other = User.createUser("other", "other@example.com", passwordEncoder.encode("P@ssw0rd!")); + other.setUserProfile(new UserProfile(other, "다른사람", null, null, null, 0)); + other.setUserStatus(UserStatus.ACTIVE); + userRepository.save(other); + + Post post = new Post(writer, "제목", "내용"); + postRepository.save(post); + + Comment comment = new Comment(post, writer, "원래 댓글"); + commentRepository.save(comment); + + String accessToken = generateAccessToken(other); + + // when & then + mvc.perform(delete("/api/posts/{postId}/comments/{commentId}", post.getId(), comment.getId()) + .header("Authorization", "Bearer " + accessToken)) + .andDo(print()) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("COMMENT_002")) + .andExpect(jsonPath("$.message").value("댓글 작성자만 수정/삭제할 수 있습니다.")); + } + + @Test + @DisplayName("댓글 삭제 실패 - 토큰 없음 → 401 Unauthorized") + void deleteComment_noToken() throws Exception { + // given + User user = User.createUser("writer4", "writer4@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "작성자4", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + Post post = new Post(user, "제목", "내용"); + postRepository.save(post); + + Comment comment = new Comment(post, user, "댓글"); + commentRepository.save(comment); + + // when & then + mvc.perform(delete("/api/posts/{postId}/comments/{commentId}", post.getId(), comment.getId())) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("AUTH_001")) + .andExpect(jsonPath("$.message").value("인증이 필요합니다.")); + } +} diff --git a/src/test/java/com/back/domain/board/controller/PostControllerTest.java b/src/test/java/com/back/domain/board/controller/PostControllerTest.java new file mode 100644 index 00000000..475d25bb --- /dev/null +++ b/src/test/java/com/back/domain/board/controller/PostControllerTest.java @@ -0,0 +1,533 @@ +package com.back.domain.board.controller; + +import com.back.domain.board.dto.PostRequest; +import com.back.domain.board.entity.Post; +import com.back.domain.board.entity.PostCategory; +import com.back.domain.board.repository.PostCategoryRepository; +import com.back.domain.board.repository.PostRepository; +import com.back.domain.user.entity.User; +import com.back.domain.user.entity.UserProfile; +import com.back.domain.user.entity.UserStatus; +import com.back.domain.user.repository.UserRepository; +import com.back.fixture.TestJwtTokenProvider; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +class PostControllerTest { + + @Autowired + private MockMvc mvc; + + @Autowired + private UserRepository userRepository; + + @Autowired + private PostRepository postRepository; + + @Autowired + private PostCategoryRepository postCategoryRepository; + + @Autowired + private TestJwtTokenProvider testJwtTokenProvider; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private ObjectMapper objectMapper; + + private String generateAccessToken(User user) { + return testJwtTokenProvider.createAccessToken(user.getId(), user.getUsername(), user.getRole().name()); + } + + // ====================== 게시글 생성 테스트 ====================== + + @Test + @DisplayName("게시글 생성 성공 → 201 Created") + void createPost_success() throws Exception { + // given: 정상 유저 생성 + User user = User.createUser("writer", "writer@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "홍길동", null, "소개글", LocalDate.of(2000, 1, 1), 1000)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + String accessToken = generateAccessToken(user); + + // 카테고리 등록 + PostCategory c1 = new PostCategory("공지사항"); + postCategoryRepository.save(c1); + + PostCategory c2 = new PostCategory("자유게시판"); + postCategoryRepository.save(c2); + + PostRequest request = new PostRequest("첫 번째 게시글", "안녕하세요, 첫 글입니다!", List.of(c1.getId(), c2.getId())); + + // when + ResultActions resultActions = mvc.perform( + post("/api/posts") + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ).andDo(print()); + + // then + resultActions + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.code").value("SUCCESS_200")) + .andExpect(jsonPath("$.data.title").value("첫 번째 게시글")) + .andExpect(jsonPath("$.data.author.nickname").value("홍길동")) + .andExpect(jsonPath("$.data.categories.length()").value(2)); + } + + @Test + @DisplayName("게시글 생성 실패 - 존재하지 않는 사용자 → 404 Not Found") + void createPost_userNotFound() throws Exception { + // given: 토큰만 발급(실제 DB엔 없음) + String fakeToken = testJwtTokenProvider.createAccessToken(999L, "ghost", "USER"); + + PostRequest request = new PostRequest("제목", "내용", null); + + // when & then + mvc.perform(post("/api/posts") + .header("Authorization", "Bearer " + fakeToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("USER_001")) + .andExpect(jsonPath("$.message").value("존재하지 않는 사용자입니다.")); + } + + @Test + @DisplayName("게시글 생성 실패 - 존재하지 않는 카테고리 → 404 Not Found") + void createPost_categoryNotFound() throws Exception { + // given: 정상 유저 + User user = User.createUser("writer2", "writer2@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "작성자", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + String accessToken = generateAccessToken(user); + + // 존재하지 않는 카테고리 ID + PostRequest request = new PostRequest("제목", "내용", List.of(999L)); + + // when & then + mvc.perform(post("/api/posts") + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("POST_003")) + .andExpect(jsonPath("$.message").value("존재하지 않는 카테고리입니다.")); + } + + @Test + @DisplayName("게시글 생성 실패 - 잘못된 요청(필드 누락) → 400 Bad Request") + void createPost_badRequest() throws Exception { + // given: 정상 유저 생성 + User user = User.createUser("writer", "writer@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "홍길동", null, "소개글", LocalDate.of(2000, 1, 1), 1000)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + String accessToken = generateAccessToken(user); + + // given: title 누락 + String invalidJson = """ + { + "content": "본문만 있음" + } + """; + + // when & then + mvc.perform(post("/api/posts") + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(invalidJson)) + .andDo(print()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("COMMON_400")) + .andExpect(jsonPath("$.message").value("잘못된 요청입니다.")); + } + + @Test + @DisplayName("게시글 생성 실패 - 토큰 없음 → 401 Unauthorized") + void createPost_noToken() throws Exception { + // given + PostRequest request = new PostRequest("제목", "내용", null); + + // when & then + mvc.perform(post("/api/posts") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("AUTH_001")) + .andExpect(jsonPath("$.message").value("인증이 필요합니다.")); + } + + // ====================== 게시글 조회 테스트 ====================== + + @Test + @DisplayName("게시글 다건 조회 성공 → 200 OK") + void getPosts_success() throws Exception { + // given: 유저 + 카테고리 + 게시글 2개 + User user = User.createUser("reader", "reader@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "홍길동", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + PostCategory c1 = new PostCategory("공지사항"); + PostCategory c2 = new PostCategory("자유게시판"); + postCategoryRepository.saveAll(List.of(c1, c2)); + + Post post1 = new Post(user, "첫 글", "내용1"); + post1.updateCategories(List.of(c1)); + postRepository.save(post1); + + Post post2 = new Post(user, "두 번째 글", "내용2"); + post2.updateCategories(List.of(c2)); + postRepository.save(post2); + + // when + mvc.perform(get("/api/posts") + .param("page", "0") + .param("size", "10") + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + // then + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.code").value("SUCCESS_200")) + .andExpect(jsonPath("$.data.items.length()").value(2)) + .andExpect(jsonPath("$.data.items[0].author.nickname").value("홍길동")); + } + + @Test + @DisplayName("게시글 단건 조회 성공 → 200 OK") + void getPost_success() throws Exception { + // given + User user = User.createUser("writer", "writer@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "이몽룡", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + PostCategory c1 = new PostCategory("공지사항"); + postCategoryRepository.save(c1); + + Post post = new Post(user, "조회 테스트 글", "조회 테스트 내용"); + post.updateCategories(List.of(c1)); + postRepository.save(post); + + // when + mvc.perform(get("/api/posts/{postId}", post.getId()) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + // then + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.code").value("SUCCESS_200")) + .andExpect(jsonPath("$.data.postId").value(post.getId())) + .andExpect(jsonPath("$.data.title").value("조회 테스트 글")) + .andExpect(jsonPath("$.data.author.nickname").value("이몽룡")) + .andExpect(jsonPath("$.data.categories[0].name").value("공지사항")); + } + + @Test + @DisplayName("게시글 단건 조회 실패 - 존재하지 않는 게시글 → 404 Not Found") + void getPost_fail_notFound() throws Exception { + mvc.perform(get("/api/posts/{postId}", 999L) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("POST_001")) + .andExpect(jsonPath("$.message").value("존재하지 않는 게시글입니다.")); + } + + // ====================== 게시글 수정 테스트 ====================== + + @Test + @DisplayName("게시글 수정 성공 → 200 OK") + void updatePost_success() throws Exception { + // given + User user = User.createUser("writer", "writer@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "홍길동", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + PostCategory c1 = new PostCategory("공지사항"); + postCategoryRepository.save(c1); + + Post post = new Post(user, "원래 제목", "원래 내용"); + post.updateCategories(List.of(c1)); + postRepository.save(post); + + String accessToken = generateAccessToken(user); + + PostCategory c2 = new PostCategory("자유게시판"); + postCategoryRepository.save(c2); + + PostRequest request = new PostRequest("수정된 게시글", "안녕하세요, 수정했습니다!", List.of(c1.getId(), c2.getId())); + + // when & then + mvc.perform(put("/api/posts/{postId}", post.getId()) + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.code").value("SUCCESS_200")) + .andExpect(jsonPath("$.data.title").value("수정된 게시글")) + .andExpect(jsonPath("$.data.categories.length()").value(2)); + } + + @Test + @DisplayName("게시글 수정 실패 - 게시글 없음 → 404 Not Found") + void updatePost_fail_notFound() throws Exception { + // given + User user = User.createUser("writer2", "writer2@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "작성자2", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + String accessToken = generateAccessToken(user); + + PostRequest request = new PostRequest("수정된 제목", "내용", List.of()); + + // when & then + mvc.perform(put("/api/posts/{postId}", 999L) + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("POST_001")) + .andExpect(jsonPath("$.message").value("존재하지 않는 게시글입니다.")); + } + + @Test + @DisplayName("게시글 수정 실패 - 작성자 아님 → 403 Forbidden") + void updatePost_fail_noPermission() throws Exception { + // given + User writer = User.createUser("writer3", "writer3@example.com", passwordEncoder.encode("P@ssw0rd!")); + writer.setUserProfile(new UserProfile(writer, "작성자3", null, null, null, 0)); + writer.setUserStatus(UserStatus.ACTIVE); + userRepository.save(writer); + + User another = User.createUser("other", "other@example.com", passwordEncoder.encode("P@ssw0rd!")); + another.setUserProfile(new UserProfile(another, "다른사람", null, null, null, 0)); + another.setUserStatus(UserStatus.ACTIVE); + userRepository.save(another); + + PostCategory c1 = new PostCategory("공지사항"); + postCategoryRepository.save(c1); + + Post post = new Post(writer, "원래 제목", "원래 내용"); + post.updateCategories(List.of(c1)); + postRepository.save(post); + + String accessToken = generateAccessToken(another); + + PostRequest request = new PostRequest("수정된 제목", "수정된 내용", List.of(c1.getId())); + + // when & then + mvc.perform(put("/api/posts/{postId}", post.getId()) + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("POST_002")) + .andExpect(jsonPath("$.message").value("게시글 작성자만 수정/삭제할 수 있습니다.")); + } + + @Test + @DisplayName("게시글 수정 실패 - 존재하지 않는 카테고리 → 404 Not Found") + void updatePost_fail_categoryNotFound() throws Exception { + // given + User user = User.createUser("writer4", "writer4@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "작성자4", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + PostCategory c1 = new PostCategory("공지사항"); + postCategoryRepository.save(c1); + + Post post = new Post(user, "원래 제목", "원래 내용"); + post.updateCategories(List.of(c1)); + postRepository.save(post); + + String accessToken = generateAccessToken(user); + + // 존재하지 않는 카테고리 ID + PostRequest request = new PostRequest("수정된 제목", "수정된 내용", List.of(999L)); + + // when & then + mvc.perform(put("/api/posts/{postId}", post.getId()) + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("POST_003")) + .andExpect(jsonPath("$.message").value("존재하지 않는 카테고리입니다.")); + } + + @Test + @DisplayName("게시글 수정 실패 - 잘못된 요청(필드 누락) → 400 Bad Request") + void updatePost_fail_badRequest() throws Exception { + // given + User user = User.createUser("writer5", "writer5@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "작성자5", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + String accessToken = generateAccessToken(user); + + String invalidJson = """ + { + "content": "본문만 있음" + } + """; + + // when & then + mvc.perform(put("/api/posts/{postId}", 1L) + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(invalidJson)) + .andDo(print()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("COMMON_400")) + .andExpect(jsonPath("$.message").value("잘못된 요청입니다.")); + } + + @Test + @DisplayName("게시글 수정 실패 - 인증 없음 → 401 Unauthorized") + void updatePost_fail_unauthorized() throws Exception { + // given + PostRequest request = new PostRequest("제목", "내용", List.of()); + + // when & then + mvc.perform(put("/api/posts/{postId}", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("AUTH_001")) + .andExpect(jsonPath("$.message").value("인증이 필요합니다.")); + } + + // ====================== 게시글 삭제 테스트 ====================== + + @Test + @DisplayName("게시글 삭제 성공 → 200 OK") + void deletePost_success() throws Exception { + // given + User user = User.createUser("writer", "writer@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "홍길동", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + Post post = new Post(user, "삭제할 제목", "삭제할 내용"); + postRepository.save(post); + + String accessToken = generateAccessToken(user); + + // when & then + mvc.perform(delete("/api/posts/{postId}", post.getId()) + .header("Authorization", "Bearer " + accessToken)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.code").value("SUCCESS_200")) + .andExpect(jsonPath("$.message").value("게시글이 삭제되었습니다.")) + .andExpect(jsonPath("$.data").doesNotExist()); + } + + @Test + @DisplayName("게시글 삭제 실패 - 게시글 없음 → 404 Not Found") + void deletePost_fail_postNotFound() throws Exception { + // given + User user = User.createUser("writer2", "writer2@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "작성자", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + String accessToken = generateAccessToken(user); + + // when & then + mvc.perform(delete("/api/posts/{postId}", 999L) + .header("Authorization", "Bearer " + accessToken)) + .andDo(print()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("POST_001")) + .andExpect(jsonPath("$.message").value("존재하지 않는 게시글입니다.")); + } + + @Test + @DisplayName("게시글 삭제 실패 - 작성자 아님 → 403 Forbidden") + void deletePost_fail_noPermission() throws Exception { + // given + User writer = User.createUser("writer3", "writer3@example.com", passwordEncoder.encode("P@ssw0rd!")); + writer.setUserProfile(new UserProfile(writer, "작성자3", null, null, null, 0)); + writer.setUserStatus(UserStatus.ACTIVE); + userRepository.save(writer); + + User another = User.createUser("other", "other@example.com", passwordEncoder.encode("P@ssw0rd!")); + another.setUserProfile(new UserProfile(another, "다른사람", null, null, null, 0)); + another.setUserStatus(UserStatus.ACTIVE); + userRepository.save(another); + + Post post = new Post(writer, "원래 제목", "원래 내용"); + postRepository.save(post); + + String accessToken = generateAccessToken(another); + + // when & then + mvc.perform(delete("/api/posts/{postId}", post.getId()) + .header("Authorization", "Bearer " + accessToken)) + .andDo(print()) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("POST_002")) + .andExpect(jsonPath("$.message").value("게시글 작성자만 수정/삭제할 수 있습니다.")); + } + + @Test + @DisplayName("게시글 삭제 실패 - 인증 없음 → 401 Unauthorized") + void deletePost_fail_unauthorized() throws Exception { + // given + Post post = new Post(); // 굳이 저장 안 해도 됨, 그냥 요청만 보냄 + + // when & then + mvc.perform(delete("/api/posts/{postId}", 1L)) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("AUTH_001")) + .andExpect(jsonPath("$.message").value("인증이 필요합니다.")); + } +} diff --git a/src/test/java/com/back/domain/board/service/CommentServiceTest.java b/src/test/java/com/back/domain/board/service/CommentServiceTest.java new file mode 100644 index 00000000..014460b5 --- /dev/null +++ b/src/test/java/com/back/domain/board/service/CommentServiceTest.java @@ -0,0 +1,346 @@ +package com.back.domain.board.service; + +import com.back.domain.board.dto.CommentListResponse; +import com.back.domain.board.dto.CommentRequest; +import com.back.domain.board.dto.CommentResponse; +import com.back.domain.board.dto.PageResponse; +import com.back.domain.board.entity.Comment; +import com.back.domain.board.entity.Post; +import com.back.domain.board.repository.CommentRepository; +import com.back.domain.board.repository.PostRepository; +import com.back.domain.user.entity.User; +import com.back.domain.user.entity.UserProfile; +import com.back.domain.user.entity.UserStatus; +import com.back.domain.user.repository.UserRepository; +import com.back.global.exception.CustomException; +import com.back.global.exception.ErrorCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import static org.assertj.core.api.Assertions.*; + +@SpringBootTest +@Transactional +@ActiveProfiles("test") +class CommentServiceTest { + + @Autowired + private CommentService commentService; + + @Autowired + private CommentRepository commentRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private PostRepository postRepository; + + // ====================== 댓글 생성 테스트 ====================== + + @Test + @DisplayName("댓글 생성 성공") + void createComment_success() { + // given: 유저 + 게시글 저장 + User user = User.createUser("writer", "writer@example.com", "encodedPwd"); + user.setUserProfile(new UserProfile(user, "작성자", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + Post post = new Post(user, "제목", "내용"); + postRepository.save(post); + + CommentRequest request = new CommentRequest("댓글 내용"); + + // when + CommentResponse response = commentService.createComment(post.getId(), request, user.getId()); + + // then + assertThat(response.content()).isEqualTo("댓글 내용"); + assertThat(response.author().nickname()).isEqualTo("작성자"); + assertThat(response.postId()).isEqualTo(post.getId()); + } + + @Test + @DisplayName("댓글 생성 실패 - 존재하지 않는 유저") + void createComment_fail_userNotFound() { + // given: 게시글 저장 + User user = User.createUser("temp", "temp@example.com", "encodedPwd"); + user.setUserProfile(new UserProfile(user, "임시유저", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + Post post = new Post(user, "제목", "내용"); + postRepository.save(post); + + CommentRequest request = new CommentRequest("댓글 내용"); + + // when & then + assertThatThrownBy(() -> commentService.createComment(post.getId(), request, 999L)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.USER_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("댓글 생성 실패 - 존재하지 않는 게시글") + void createComment_fail_postNotFound() { + // given: 유저 저장 + User user = User.createUser("writer2", "writer2@example.com", "encodedPwd"); + user.setUserProfile(new UserProfile(user, "작성자2", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + CommentRequest request = new CommentRequest("댓글 내용"); + + // when & then + assertThatThrownBy(() -> commentService.createComment(999L, request, user.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.POST_NOT_FOUND.getMessage()); + } + + // ====================== 댓글 조회 테스트 ====================== + + @Test + @DisplayName("댓글 목록 조회 성공 - 부모 + 자식 포함") + void getComments_success() { + // given: 유저 + 게시글 + User user = User.createUser("writer", "writer@example.com", "pwd"); + user.setUserProfile(new UserProfile(user, "홍길동", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + Post post = new Post(user, "제목", "내용"); + postRepository.save(post); + + // 부모 댓글 + Comment parent = new Comment(post, user, "부모 댓글", null); + commentRepository.save(parent); + + // 자식 댓글 + Comment child = new Comment(post, user, "자식 댓글", parent); + commentRepository.save(child); + + Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.ASC, "createdAt")); + + // when + PageResponse response = commentService.getComments(post.getId(), pageable); + + // then + assertThat(response.items()).hasSize(1); // 부모만 페이징 결과 + CommentListResponse parentRes = response.items().getFirst(); + assertThat(parentRes.getContent()).isEqualTo("부모 댓글"); + assertThat(parentRes.getChildren()).hasSize(1); + assertThat(parentRes.getChildren().getFirst().getContent()).isEqualTo("자식 댓글"); + } + + @Test + @DisplayName("댓글 목록 조회 실패 - 게시글 없음") + void getComments_fail_postNotFound() { + Pageable pageable = PageRequest.of(0, 10); + + assertThatThrownBy(() -> + commentService.getComments(999L, pageable) + ).isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.POST_NOT_FOUND.getMessage()); + } + + // ====================== 댓글 수정 테스트 ====================== + + @Test + @DisplayName("댓글 수정 성공") + void updateComment_success() { + // given: 유저 + 게시글 + 댓글 + User user = User.createUser("writer", "writer@example.com", "encodedPwd"); + user.setUserProfile(new UserProfile(user, "작성자", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + Post post = new Post(user, "제목", "내용"); + postRepository.save(post); + + Comment comment = new Comment(post, user, "원래 댓글"); + commentRepository.save(comment); + + CommentRequest updateRequest = new CommentRequest("수정된 댓글"); + + // when + CommentResponse response = commentService.updateComment(post.getId(), comment.getId(), updateRequest, user.getId()); + + // then + assertThat(response.content()).isEqualTo("수정된 댓글"); + assertThat(response.commentId()).isEqualTo(comment.getId()); + } + + @Test + @DisplayName("댓글 수정 실패 - 존재하지 않는 게시글") + void updateComment_fail_postNotFound() { + // given: 유저 + 댓글 + User user = User.createUser("writer2", "writer2@example.com", "encodedPwd"); + user.setUserProfile(new UserProfile(user, "작성자2", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + Post post = new Post(user, "제목", "내용"); + postRepository.save(post); + + Comment comment = new Comment(post, user, "댓글"); + commentRepository.save(comment); + + CommentRequest updateRequest = new CommentRequest("수정된 댓글"); + + // when & then + assertThatThrownBy(() -> + commentService.updateComment(999L, comment.getId(), updateRequest, user.getId()) + ).isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.POST_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("댓글 수정 실패 - 존재하지 않는 댓글") + void updateComment_fail_commentNotFound() { + // given: 유저 + 게시글 + User user = User.createUser("writer3", "writer3@example.com", "encodedPwd"); + user.setUserProfile(new UserProfile(user, "작성자3", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + Post post = new Post(user, "제목", "내용"); + postRepository.save(post); + + CommentRequest updateRequest = new CommentRequest("수정된 댓글"); + + // when & then + assertThatThrownBy(() -> + commentService.updateComment(post.getId(), 999L, updateRequest, user.getId()) + ).isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.COMMENT_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("댓글 수정 실패 - 작성자가 아님") + void updateComment_fail_noPermission() { + // given: 유저 2명 + 게시글 + 댓글 + User writer = User.createUser("writer", "writer@example.com", "encodedPwd"); + writer.setUserProfile(new UserProfile(writer, "작성자", null, null, null, 0)); + writer.setUserStatus(UserStatus.ACTIVE); + userRepository.save(writer); + + User other = User.createUser("other", "other@example.com", "encodedPwd"); + other.setUserProfile(new UserProfile(other, "다른사람", null, null, null, 0)); + other.setUserStatus(UserStatus.ACTIVE); + userRepository.save(other); + + Post post = new Post(writer, "제목", "내용"); + postRepository.save(post); + + Comment comment = new Comment(post, writer, "원래 댓글"); + commentRepository.save(comment); + + CommentRequest updateRequest = new CommentRequest("수정된 댓글"); + + // when & then + assertThatThrownBy(() -> + commentService.updateComment(post.getId(), comment.getId(), updateRequest, other.getId()) + ).isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.COMMENT_NO_PERMISSION.getMessage()); + } + + // ====================== 댓글 삭제 테스트 ====================== + + @Test + @DisplayName("댓글 삭제 성공") + void deleteComment_success() { + // given + User user = User.createUser("writer", "writer@example.com", "encodedPwd"); + user.setUserProfile(new UserProfile(user, "작성자", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + Post post = new Post(user, "제목", "내용"); + postRepository.save(post); + + Comment comment = new Comment(post, user, "삭제할 댓글"); + commentRepository.save(comment); + + // when + commentService.deleteComment(post.getId(), comment.getId(), user.getId()); + + // then + assertThat(commentRepository.findById(comment.getId())).isEmpty(); + } + + @Test + @DisplayName("댓글 삭제 실패 - 존재하지 않는 게시글") + void deleteComment_fail_postNotFound() { + // given + User user = User.createUser("writer2", "writer2@example.com", "encodedPwd"); + user.setUserProfile(new UserProfile(user, "작성자2", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + Post post = new Post(user, "제목", "내용"); + postRepository.save(post); + + Comment comment = new Comment(post, user, "댓글"); + commentRepository.save(comment); + + // when & then + assertThatThrownBy(() -> + commentService.deleteComment(999L, comment.getId(), user.getId()) + ).isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.POST_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("댓글 삭제 실패 - 존재하지 않는 댓글") + void deleteComment_fail_commentNotFound() { + // given + User user = User.createUser("writer3", "writer3@example.com", "encodedPwd"); + user.setUserProfile(new UserProfile(user, "작성자3", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + Post post = new Post(user, "제목", "내용"); + postRepository.save(post); + + // when & then + assertThatThrownBy(() -> + commentService.deleteComment(post.getId(), 999L, user.getId()) + ).isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.COMMENT_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("댓글 삭제 실패 - 작성자가 아님") + void deleteComment_fail_noPermission() { + // given: 작성자 + 다른 사용자 + User writer = User.createUser("writer", "writer@example.com", "encodedPwd"); + writer.setUserProfile(new UserProfile(writer, "작성자", null, null, null, 0)); + writer.setUserStatus(UserStatus.ACTIVE); + userRepository.save(writer); + + User other = User.createUser("other", "other@example.com", "encodedPwd"); + other.setUserProfile(new UserProfile(other, "다른사람", null, null, null, 0)); + other.setUserStatus(UserStatus.ACTIVE); + userRepository.save(other); + + Post post = new Post(writer, "제목", "내용"); + postRepository.save(post); + + Comment comment = new Comment(post, writer, "원래 댓글"); + commentRepository.save(comment); + + // when & then + assertThatThrownBy(() -> + commentService.deleteComment(post.getId(), comment.getId(), other.getId()) + ).isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.COMMENT_NO_PERMISSION.getMessage()); + } +} diff --git a/src/test/java/com/back/domain/board/service/PostServiceTest.java b/src/test/java/com/back/domain/board/service/PostServiceTest.java new file mode 100644 index 00000000..ec76332c --- /dev/null +++ b/src/test/java/com/back/domain/board/service/PostServiceTest.java @@ -0,0 +1,337 @@ +package com.back.domain.board.service; + +import com.back.domain.board.dto.*; +import com.back.domain.board.entity.Post; +import com.back.domain.board.entity.PostCategory; +import com.back.domain.board.repository.PostCategoryRepository; +import com.back.domain.board.repository.PostRepository; +import com.back.domain.user.entity.User; +import com.back.domain.user.entity.UserProfile; +import com.back.domain.user.entity.UserStatus; +import com.back.domain.user.repository.UserRepository; +import com.back.global.exception.CustomException; +import com.back.global.exception.ErrorCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static org.assertj.core.api.Assertions.*; + +@SpringBootTest +@Transactional +@ActiveProfiles("test") +class PostServiceTest { + + @Autowired + private PostService postService; + + @Autowired + private PostRepository postRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private PostCategoryRepository postCategoryRepository; + + // ====================== 게시글 생성 테스트 ====================== + + @Test + @DisplayName("게시글 생성 성공 - 카테고리 포함") + void createPost_success_withCategories() { + // given: 유저 + 카테고리 저장 + User user = User.createUser("writer", "writer@example.com", "encodedPwd"); + user.setUserProfile(new UserProfile(user, "작성자", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + PostCategory category = new PostCategory("공지"); + postCategoryRepository.save(category); + + PostRequest request = new PostRequest("제목", "내용", List.of(category.getId())); + + // when + PostResponse response = postService.createPost(request, user.getId()); + + // then + assertThat(response.title()).isEqualTo("제목"); + assertThat(response.content()).isEqualTo("내용"); + assertThat(response.author().nickname()).isEqualTo("작성자"); + assertThat(response.categories()).hasSize(1); + assertThat(response.categories().getFirst().name()).isEqualTo("공지"); + } + + @Test + @DisplayName("게시글 생성 실패 - 존재하지 않는 유저") + void createPost_fail_userNotFound() { + // given + PostRequest request = new PostRequest("제목", "내용", null); + + // when & then + assertThatThrownBy(() -> postService.createPost(request, 999L)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.USER_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("게시글 생성 실패 - 존재하지 않는 카테고리 ID 포함") + void createPost_fail_categoryNotFound() { + // given: 유저는 정상 + User user = User.createUser("writer2", "writer2@example.com", "encodedPwd"); + user.setUserProfile(new UserProfile(user, "작성자2", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + // 실제 저장 안 된 카테고리 ID 요청 + PostRequest request = new PostRequest("제목", "내용", List.of(100L, 200L)); + + // when & then + assertThatThrownBy(() -> postService.createPost(request, user.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.CATEGORY_NOT_FOUND.getMessage()); + } + + // ====================== 게시글 조회 테스트 ====================== + + + @Test + @DisplayName("게시글 다건 조회 성공 - 페이징 + 카테고리") + void getPosts_success() { + // given + User user = User.createUser("writer3", "writer3@example.com", "encodedPwd"); + user.setUserProfile(new UserProfile(user, "작성자3", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + PostCategory c1 = new PostCategory("공지사항"); + PostCategory c2 = new PostCategory("자유게시판"); + postCategoryRepository.saveAll(List.of(c1, c2)); + + Post post1 = new Post(user, "첫 번째 글", "내용1"); + post1.updateCategories(List.of(c1)); + postRepository.save(post1); + + Post post2 = new Post(user, "두 번째 글", "내용2"); + post2.updateCategories(List.of(c2)); + postRepository.save(post2); + + Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt")); + + // when + PageResponse response = postService.getPosts(null, null, null, pageable); + + // then + assertThat(response.items()).hasSize(2); + assertThat(response.items().get(0).getTitle()).isEqualTo("두 번째 글"); + assertThat(response.items().get(1).getTitle()).isEqualTo("첫 번째 글"); + } + + @Test + @DisplayName("게시글 단건 조회 성공") + void getPost_success() { + // given + User user = User.createUser("reader", "reader@example.com", "encodedPwd"); + user.setUserProfile(new UserProfile(user, "독자", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + PostCategory category = new PostCategory("공지"); + postCategoryRepository.save(category); + + Post post = new Post(user, "조회용 제목", "조회용 내용"); + post.updateCategories(List.of(category)); + postRepository.save(post); + + // when + PostDetailResponse response = postService.getPost(post.getId()); + + // then + assertThat(response.postId()).isEqualTo(post.getId()); + assertThat(response.title()).isEqualTo("조회용 제목"); + assertThat(response.content()).isEqualTo("조회용 내용"); + assertThat(response.author().nickname()).isEqualTo("독자"); + assertThat(response.categories()).extracting("name").containsExactly("공지"); + assertThat(response.likeCount()).isZero(); + assertThat(response.bookmarkCount()).isZero(); + assertThat(response.commentCount()).isZero(); + } + + @Test + @DisplayName("게시글 단건 조회 실패 - 존재하지 않는 게시글") + void getPost_fail_postNotFound() { + // when & then + assertThatThrownBy(() -> postService.getPost(999L)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.POST_NOT_FOUND.getMessage()); + } + + // ====================== 게시글 수정 테스트 ====================== + + @Test + @DisplayName("게시글 수정 성공 - 작성자 본인") + void updatePost_success() { + // given + User user = User.createUser("writer", "writer@example.com", "encodedPwd"); + user.setUserProfile(new UserProfile(user, "작성자", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + PostCategory oldCategory = new PostCategory("공지"); + PostCategory newCategory = new PostCategory("자유"); + postCategoryRepository.saveAll(List.of(oldCategory, newCategory)); + + Post post = new Post(user, "원래 제목", "원래 내용"); + post.updateCategories(List.of(oldCategory)); + postRepository.save(post); + + PostRequest request = new PostRequest("수정된 제목", "수정된 내용", List.of(newCategory.getId())); + + // when + PostResponse response = postService.updatePost(post.getId(), request, user.getId()); + + // then + assertThat(response.title()).isEqualTo("수정된 제목"); + assertThat(response.content()).isEqualTo("수정된 내용"); + assertThat(response.categories()).hasSize(1); + assertThat(response.categories().getFirst().name()).isEqualTo("자유"); + } + + @Test + @DisplayName("게시글 수정 실패 - 게시글 없음") + void updatePost_fail_postNotFound() { + // given + User user = User.createUser("writer2", "writer2@example.com", "encodedPwd"); + user.setUserProfile(new UserProfile(user, "작성자2", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + PostRequest request = new PostRequest("제목", "내용", List.of()); + + // when & then + assertThatThrownBy(() -> postService.updatePost(999L, request, user.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.POST_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("게시글 수정 실패 - 작성자 아님") + void updatePost_fail_noPermission() { + // given: 게시글 작성자 + User writer = User.createUser("writer3", "writer3@example.com", "encodedPwd"); + writer.setUserProfile(new UserProfile(writer, "작성자3", null, null, null, 0)); + writer.setUserStatus(UserStatus.ACTIVE); + userRepository.save(writer); + + // 다른 사용자 + User another = User.createUser("other", "other@example.com", "encodedPwd"); + another.setUserProfile(new UserProfile(another, "다른사람", null, null, null, 0)); + another.setUserStatus(UserStatus.ACTIVE); + userRepository.save(another); + + PostCategory category = new PostCategory("공지"); + postCategoryRepository.save(category); + + Post post = new Post(writer, "원래 제목", "원래 내용"); + post.updateCategories(List.of(category)); + postRepository.save(post); + + PostRequest request = new PostRequest("수정된 제목", "수정된 내용", List.of(category.getId())); + + // when & then + assertThatThrownBy(() -> postService.updatePost(post.getId(), request, another.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.POST_NO_PERMISSION.getMessage()); + } + + @Test + @DisplayName("게시글 수정 실패 - 존재하지 않는 카테고리 포함") + void updatePost_fail_categoryNotFound() { + // given + User user = User.createUser("writer4", "writer4@example.com", "encodedPwd"); + user.setUserProfile(new UserProfile(user, "작성자4", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + PostCategory category = new PostCategory("공지"); + postCategoryRepository.save(category); + + Post post = new Post(user, "원래 제목", "원래 내용"); + post.updateCategories(List.of(category)); + postRepository.save(post); + + // 실제 DB에는 없는 카테고리 ID 전달 + PostRequest request = new PostRequest("수정된 제목", "수정된 내용", List.of(999L)); + + // when & then + assertThatThrownBy(() -> postService.updatePost(post.getId(), request, user.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.CATEGORY_NOT_FOUND.getMessage()); + } + + // ====================== 게시글 삭제 테스트 ====================== + + @Test + @DisplayName("게시글 삭제 성공 - 작성자 본인") + void deletePost_success() { + // given + User user = User.createUser("writer", "writer@example.com", "encodedPwd"); + user.setUserProfile(new UserProfile(user, "작성자", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + Post post = new Post(user, "삭제 대상 제목", "삭제 대상 내용"); + postRepository.save(post); + + // when + postService.deletePost(post.getId(), user.getId()); + + // then + assertThat(postRepository.findById(post.getId())).isEmpty(); + } + + @Test + @DisplayName("게시글 삭제 실패 - 게시글 없음") + void deletePost_fail_postNotFound() { + // given + User user = User.createUser("writer2", "writer2@example.com", "encodedPwd"); + user.setUserProfile(new UserProfile(user, "작성자2", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + // when & then + assertThatThrownBy(() -> postService.deletePost(999L, user.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.POST_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("게시글 삭제 실패 - 작성자 아님") + void deletePost_fail_noPermission() { + // given + User writer = User.createUser("writer3", "writer3@example.com", "encodedPwd"); + writer.setUserProfile(new UserProfile(writer, "작성자3", null, null, null, 0)); + writer.setUserStatus(UserStatus.ACTIVE); + userRepository.save(writer); + + User another = User.createUser("other", "other@example.com", "encodedPwd"); + another.setUserProfile(new UserProfile(another, "다른사람", null, null, null, 0)); + another.setUserStatus(UserStatus.ACTIVE); + userRepository.save(another); + + Post post = new Post(writer, "원래 제목", "원래 내용"); + postRepository.save(post); + + // when & then + assertThatThrownBy(() -> postService.deletePost(post.getId(), another.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.POST_NO_PERMISSION.getMessage()); + } +} diff --git a/src/test/java/com/back/domain/chat/room/controller/RoomChatApiControllerTest.java b/src/test/java/com/back/domain/chat/room/controller/RoomChatApiControllerTest.java index 0d307141..71a43ee6 100644 --- a/src/test/java/com/back/domain/chat/room/controller/RoomChatApiControllerTest.java +++ b/src/test/java/com/back/domain/chat/room/controller/RoomChatApiControllerTest.java @@ -290,7 +290,7 @@ void t8() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content(requestBody)) .andExpect(status().isForbidden()) - .andExpect(jsonPath("$.code").value("ROOM_012")) + .andExpect(jsonPath("$.code").value("ROOM_013")) .andExpect(jsonPath("$.message").value("채팅 삭제 권한이 없습니다. 방장 또는 부방장만 가능합니다.")); } @@ -364,7 +364,7 @@ void t10() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content(requestBody)) .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value("ROOM_013")) + .andExpect(jsonPath("$.code").value("ROOM_014")) .andExpect(jsonPath("$.message").value("삭제 확인 메시지가 일치하지 않습니다.")); } diff --git a/src/test/java/com/back/domain/study/plan/controller/StudyPlanControllerTest.java b/src/test/java/com/back/domain/study/plan/controller/StudyPlanControllerTest.java index 62ff8ad2..36a4ebd2 100644 --- a/src/test/java/com/back/domain/study/plan/controller/StudyPlanControllerTest.java +++ b/src/test/java/com/back/domain/study/plan/controller/StudyPlanControllerTest.java @@ -36,6 +36,8 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.hasSize; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.BDDMockito.given; @@ -133,7 +135,7 @@ private StudyPlan createDailyPlan() { } @Test - @DisplayName("단발성 계획 생성") + @DisplayName("단발성 계획 생성 - 하나에만 밀리초가 들어가도 되는가?!") void t1() throws Exception { ResultActions resultActions = mvc.perform(post("/api/plans") @@ -142,8 +144,8 @@ void t1() throws Exception { .content(""" { "subject": "단발성 계획", - "startDate": "2025-09-26T10:46:00", - "endDate": "2025-09-26T11:46:00", + "startDate": "2025-10-03T17:00:00", + "endDate": "2025-10-03T18:30:00.000", "color": "RED" } """)) @@ -160,8 +162,37 @@ void t1() throws Exception { .andExpect(jsonPath("$.message").value("학습 계획이 성공적으로 생성되었습니다.")) .andExpect(jsonPath("$.data.subject").value("단발성 계획")) .andExpect(jsonPath("$.data.color").value("RED")) - .andExpect(jsonPath("$.data.startDate").value("2025-09-26T10:46:00")) - .andExpect(jsonPath("$.data.endDate").value("2025-09-26T11:46:00")) + .andExpect(jsonPath("$.data.startDate").value("2025-10-03T17:00:00")) + .andExpect(jsonPath("$.data.endDate").value("2025-10-03T18:30:00")) + .andExpect(jsonPath("$.data.repeatRule").doesNotExist()); + + } + + @Test + @DisplayName("단발성 계획 생성 - 밀리초까지도 둘 다 전송받는 경우") + void t1_1() throws Exception { + + ResultActions resultActions = mvc.perform(post("/api/plans") + .header("Authorization", "Bearer faketoken") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "subject": "단발성 계획 - 밀리초 포함", + "startDate": "2025-09-21T05:00:00.000", + "endDate": "2025-09-21T07:00:00.000", + "color": "RED" + } + """)) + .andDo(print()); + + resultActions + .andExpect(status().isOk()) // 200 OK인지 확인 + .andExpect(handler().handlerType(StudyPlanController.class)) + .andExpect(handler().methodName("createStudyPlan")) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.message").value("학습 계획이 성공적으로 생성되었습니다.")) + .andExpect(jsonPath("$.data.startDate").value("2025-09-21T05:00:00")) + .andExpect(jsonPath("$.data.endDate").value("2025-09-21T07:00:00")) .andExpect(jsonPath("$.data.repeatRule").doesNotExist()); } @@ -199,8 +230,8 @@ void t2() throws Exception { .andExpect(jsonPath("$.data.startDate").value("2025-09-26T10:46:00")) .andExpect(jsonPath("$.data.endDate").value("2025-09-26T11:46:00")) .andExpect(jsonPath("$.data.repeatRule.frequency").value("DAILY")) - .andExpect(jsonPath("$.data.repeatRule.repeatInterval").value(1)) - .andExpect(jsonPath("$.data.repeatRule.byDay", Matchers.hasSize(0))) + .andExpect(jsonPath("$.data.repeatRule.intervalValue").value(1)) + .andExpect(jsonPath("$.data.repeatRule.byDay", hasSize(0))) .andExpect(jsonPath("$.data.repeatRule.untilDate").value("2025-12-31")); } @@ -295,7 +326,7 @@ void t5() throws Exception { .andExpect(jsonPath("$.message").value("해당 날짜의 계획을 조회했습니다.")) .andExpect(jsonPath("$.data.date").value("2025-09-29")) .andExpect(jsonPath("$.data.totalCount").value(1)) - .andExpect(jsonPath("$.data.plans", Matchers.hasSize(1))) + .andExpect(jsonPath("$.data.plans", hasSize(1))) .andExpect(jsonPath("$.data.plans[0].subject").value("Java 공부")) .andExpect(jsonPath("$.data.plans[0].color").value("BLUE")) .andExpect(jsonPath("$.data.plans[0].startDate").value("2025-09-29T09:00:00")) @@ -318,7 +349,7 @@ void t6() throws Exception { .andExpect(jsonPath("$.message").value("해당 날짜의 계획을 조회했습니다.")) .andExpect(jsonPath("$.data.date").value("2025-09-01")) .andExpect(jsonPath("$.data.totalCount").value(0)) - .andExpect(jsonPath("$.data.plans", Matchers.hasSize(0))); + .andExpect(jsonPath("$.data.plans", hasSize(0))); } @Test @DisplayName("계획 조회 - 기간별 조회") @@ -357,7 +388,7 @@ void t7() throws Exception { .andExpect(handler().methodName("getStudyPlansForPeriod")) .andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.message").value("기간별 계획을 조회했습니다.")) - .andExpect(jsonPath("$.data", Matchers.hasSize(7))); + .andExpect(jsonPath("$.data", hasSize(7))); } @Test @@ -403,12 +434,13 @@ void t9() throws Exception { .andExpect(jsonPath("$.data.subject").value("수정된 최종 계획 (PUT)")) .andExpect(jsonPath("$.data.color").value("RED")) .andExpect(jsonPath("$.data.startDate").value("2025-10-10T14:00:00")) - .andExpect(jsonPath("$.data.endDate").value("2025-10-10T16:00:00")); + .andExpect(jsonPath("$.data.endDate").value("2025-10-10T16:00:00")) + .andExpect(jsonPath("$.data.repeatRule").doesNotExist()); } @Test - @DisplayName("계획 수정 - 단발성 (반복 규칙 추가 시도)") + @DisplayName("계획 수정 - 단발성 (반복 규칙 추가 시도 + WEEKLY 인 경우 byDay 자동 적용)") void t9_1() throws Exception { StudyPlan originalPlan = createSinglePlan(); Long planId = originalPlan.getId(); @@ -423,7 +455,7 @@ void t9_1() throws Exception { "endDate": "2025-10-01T12:00:00", "color": "RED", "repeatRule": { - "frequency": "DAILY", + "frequency": "WEEKLY", "repeatInterval": 1, "untilDate": "2025-12-31" } @@ -434,11 +466,14 @@ void t9_1() throws Exception { resultActions .andExpect(status().isOk()) // 400 Bad Request .andExpect(handler().handlerType(StudyPlanController.class)) - .andExpect(jsonPath("$.success").value(true)); + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.subject").value("수정된 최종 계획 (PUT)")) + .andExpect(jsonPath("$.data.repeatRule.byDay", hasSize(1))) + .andExpect(jsonPath("$.data.repeatRule.byDay[0]").value("WED")); } @Test - @DisplayName("계획 수정 - 반복성 단일 수정") + @DisplayName("계획 수정 - 반복성 가상 계획 단일 수정") void t10() throws Exception { StudyPlan originalPlan = createDailyPlan(); Long planId = originalPlan.getId(); @@ -467,13 +502,12 @@ void t10() throws Exception { .andExpect(jsonPath("$.data.color").value("BLUE")) .andExpect(jsonPath("$.data.startDate").value("2025-10-10T14:00:00")) .andExpect(jsonPath("$.data.endDate").value("2025-10-10T16:00:00")); - - //조회가 잘 되는지도 검증 - mvc.perform(get("/api/plans/date/2025-10-10") + //원본은 변경사항 없이 조회가 잘 되는지도 검증 + mvc.perform(get("/api/plans/date/2025-10-01") .header("Authorization", "Bearer faketoken") .contentType(MediaType.APPLICATION_JSON)) .andDo(print()) - .andExpect(jsonPath("$.data.plans[0].subject").value("수정된 반복 계획 (PUT)")); + .andExpect(jsonPath("$.data.plans[0].subject").value("매일 반복 계획")); } @Test @@ -509,6 +543,7 @@ void t11() throws Exception { .andExpect(jsonPath("$.data.plans[0].subject").value("매일 반복 계획")); } + @Test @DisplayName("계획 삭제 - 단발성 단독 삭제") void t12() throws Exception { @@ -524,7 +559,12 @@ void t12() throws Exception { .andExpect(status().isOk()) // 200 OK .andExpect(handler().handlerType(StudyPlanController.class)) .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.message").value("학습 계획이 성공적으로 삭제되었습니다.")); + .andExpect(jsonPath("$.message").value("학습 계획이 성공적으로 삭제되었습니다.")) + .andExpect(jsonPath("$.data.id").value(planId)) + .andExpect(jsonPath("$.data.subject").value(originalPlan.getSubject())) + .andExpect(jsonPath("$.data.color").value(Color.RED.name())) + .andExpect(jsonPath("$.data.deletedDate").value("2025-10-01")) + .andExpect(jsonPath("$.data.applyScope").value("THIS_ONLY")); // DB에서 실제로 삭제되었는지 확인 boolean exists = studyPlanRepository.existsById(planId); @@ -546,7 +586,14 @@ void t13() throws Exception { .andExpect(status().isOk()) // 200 OK .andExpect(handler().handlerType(StudyPlanController.class)) .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.message").value("학습 계획이 성공적으로 삭제되었습니다.")); + .andExpect(jsonPath("$.message").value("학습 계획이 성공적으로 삭제되었습니다.")) + .andExpect(jsonPath("$.data.id").value(planId)) + .andExpect(jsonPath("$.data.subject").value(originalPlan.getSubject())) + .andExpect(jsonPath("$.data.deletedDate").value("2025-10-01")) + .andExpect(jsonPath("$.data.applyScope").value("THIS_ONLY")) + .andExpect(jsonPath("$.data.startDate").value("2025-10-01T12:00:00")) + .andExpect(jsonPath("$.data.endDate").value("2025-10-01T13:00:00")); + // 10월 1일에 해당하는 계획은 없어야함 mvc.perform(get("/api/plans/date/2025-10-01") .header("Authorization", "Bearer faketoken") @@ -577,7 +624,10 @@ void t14() throws Exception { .andExpect(status().isOk()) // 200 OK .andExpect(handler().handlerType(StudyPlanController.class)) .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.message").value("학습 계획이 성공적으로 삭제되었습니다.")); + .andExpect(jsonPath("$.message").value("학습 계획이 성공적으로 삭제되었습니다.")) + .andExpect(jsonPath("$.data.id").value(planId)) + .andExpect(jsonPath("$.data.deletedDate").value("2025-10-01")) + .andExpect(jsonPath("$.data.applyScope").value("FROM_THIS_DATE")); // 10월 10일에 해당하는 계획도 없어야함 mvc.perform(get("/api/plans/date/2025-10-10") @@ -602,7 +652,12 @@ void t15() throws Exception { .andExpect(status().isOk()) // 200 OK .andExpect(handler().handlerType(StudyPlanController.class)) .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.message").value("학습 계획이 성공적으로 삭제되었습니다.")); + .andExpect(jsonPath("$.message").value("학습 계획이 성공적으로 삭제되었습니다.")) + .andExpect(jsonPath("$.data.id").value(planId)) + .andExpect(jsonPath("$.data.deletedDate").value("2025-10-10")) + .andExpect(jsonPath("$.data.applyScope").value("FROM_THIS_DATE")) + .andExpect(jsonPath("$.data.startDate").value("2025-10-10T12:00:00")) + .andExpect(jsonPath("$.data.endDate").value("2025-10-10T13:00:00")); // 10월 1일에 해당하는 계획은 있어야함 mvc.perform(get("/api/plans/date/2025-10-01") @@ -611,14 +666,13 @@ void t15() throws Exception { .andDo(print()) .andExpect(jsonPath("$.data.totalCount").value(1)); - // 10.10 이후에 해당하는 계획은 없어야함 + // 10.10 이후에 해당하는 계획은 없어야함 mvc.perform(get("/api/plans?start=2025-10-10&end=2025-10-15") .header("Authorization", "Bearer faketoken") .contentType(MediaType.APPLICATION_JSON)) .andDo(print()) //검색 결과가 없다면 빈 배열 - .andExpect(jsonPath("$.data", Matchers.hasSize(0))); + .andExpect(jsonPath("$.data", hasSize(0))); } - } \ No newline at end of file diff --git a/src/test/java/com/back/domain/studyroom/controller/RoomControllerTest.java b/src/test/java/com/back/domain/studyroom/controller/RoomControllerTest.java index e1be5d07..ad9c2d25 100644 --- a/src/test/java/com/back/domain/studyroom/controller/RoomControllerTest.java +++ b/src/test/java/com/back/domain/studyroom/controller/RoomControllerTest.java @@ -66,7 +66,7 @@ void setUp() { userProfile.setNickname("테스트유저"); testUser.setUserProfile(userProfile); - // 테스트 방 생성 + // 테스트 방 생성 (WebRTC 사용) testRoom = Room.create( "테스트 방", "테스트 설명", @@ -74,7 +74,8 @@ void setUp() { null, 10, testUser, - null + null, + true // useWebRTC ); // 테스트 멤버 생성 @@ -94,7 +95,8 @@ void createRoom() { "테스트 설명", false, null, - 10 + 10, + true // useWebRTC ); given(roomService.createRoom( @@ -103,11 +105,16 @@ void createRoom() { anyBoolean(), any(), anyInt(), - eq(1L) + eq(1L), + anyBoolean() // useWebRTC 파라미터 추가 )).willReturn(testRoom); + + RoomResponse roomResponse = RoomResponse.from(testRoom, 1); + given(roomService.toRoomResponse(any(Room.class))).willReturn(roomResponse); // when ResponseEntity> response = roomController.createRoom(request); + // then assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); assertThat(response.getBody()).isNotNull(); @@ -121,8 +128,10 @@ void createRoom() { anyBoolean(), any(), anyInt(), - eq(1L) + eq(1L), + anyBoolean() // useWebRTC 파라미터 추가 ); + verify(roomService, times(1)).toRoomResponse(any(Room.class)); } @Test @@ -174,6 +183,9 @@ void getRooms() { 1 ); given(roomService.getJoinableRooms(any())).willReturn(roomPage); + + List roomResponses = Arrays.asList(RoomResponse.from(testRoom, 1)); + given(roomService.toRoomResponseList(anyList())).willReturn(roomResponses); // when ResponseEntity>> response = roomController.getRooms(0, 20); @@ -185,6 +197,7 @@ void getRooms() { assertThat(response.getBody().getData().get("rooms")).isNotNull(); verify(roomService, times(1)).getJoinableRooms(any()); + verify(roomService, times(1)).toRoomResponseList(anyList()); } @Test @@ -195,6 +208,13 @@ void getRoomDetail() { given(roomService.getRoomDetail(eq(1L), eq(1L))).willReturn(testRoom); given(roomService.getRoomMembers(eq(1L), eq(1L))).willReturn(Arrays.asList(testMember)); + + RoomDetailResponse roomDetailResponse = RoomDetailResponse.of( + testRoom, + 1, + Arrays.asList(RoomMemberResponse.from(testMember)) + ); + given(roomService.toRoomDetailResponse(any(Room.class), anyList())).willReturn(roomDetailResponse); // when ResponseEntity> response = roomController.getRoomDetail(1L); @@ -208,6 +228,7 @@ void getRoomDetail() { verify(currentUser, times(1)).getUserId(); verify(roomService, times(1)).getRoomDetail(eq(1L), eq(1L)); verify(roomService, times(1)).getRoomMembers(eq(1L), eq(1L)); + verify(roomService, times(1)).toRoomDetailResponse(any(Room.class), anyList()); } @Test @@ -226,7 +247,11 @@ void getMyRooms() { } given(roomService.getUserRooms(eq(1L))).willReturn(Arrays.asList(testRoom)); - given(roomService.getUserRoomRole(eq(1L), eq(1L))).willReturn(RoomRole.HOST); + + List myRoomResponses = Arrays.asList( + MyRoomResponse.of(testRoom, 1, RoomRole.HOST) + ); + given(roomService.toMyRoomResponseList(anyList(), eq(1L))).willReturn(myRoomResponses); // when ResponseEntity>> response = roomController.getMyRooms(); @@ -240,6 +265,7 @@ void getMyRooms() { verify(currentUser, times(1)).getUserId(); verify(roomService, times(1)).getUserRooms(eq(1L)); + verify(roomService, times(1)).toMyRoomResponseList(anyList(), eq(1L)); } @Test @@ -328,6 +354,9 @@ void getPopularRooms() { 1 ); given(roomService.getPopularRooms(any())).willReturn(roomPage); + + List roomResponses = Arrays.asList(RoomResponse.from(testRoom, 1)); + given(roomService.toRoomResponseList(anyList())).willReturn(roomResponses); // when ResponseEntity>> response = roomController.getPopularRooms(0, 20); @@ -339,5 +368,127 @@ void getPopularRooms() { assertThat(response.getBody().getData().get("rooms")).isNotNull(); verify(roomService, times(1)).getPopularRooms(any()); + verify(roomService, times(1)).toRoomResponseList(anyList()); + + } + + @Test + @DisplayName("방 생성 API - WebRTC 활성화 테스트") + void createRoom_WithWebRTC() { + // given + given(currentUser.getUserId()).willReturn(1L); + + CreateRoomRequest request = new CreateRoomRequest( + "WebRTC 방", + "화상 채팅 가능", + false, + null, + 10, + true // WebRTC 활성화 + ); + + Room webRTCRoom = Room.create( + "WebRTC 방", + "화상 채팅 가능", + false, + null, + 10, + testUser, + null, + true + ); + + given(roomService.createRoom( + anyString(), + anyString(), + anyBoolean(), + any(), + anyInt(), + eq(1L), + eq(true) // WebRTC true 검증 + )).willReturn(webRTCRoom); + + RoomResponse roomResponse = RoomResponse.from(webRTCRoom, 1); + given(roomService.toRoomResponse(any(Room.class))).willReturn(roomResponse); + + // when + ResponseEntity> response = roomController.createRoom(request); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getData().getAllowCamera()).isTrue(); + assertThat(response.getBody().getData().getAllowAudio()).isTrue(); + assertThat(response.getBody().getData().getAllowScreenShare()).isTrue(); + + verify(roomService, times(1)).createRoom( + anyString(), + anyString(), + anyBoolean(), + any(), + anyInt(), + eq(1L), + eq(true) + ); + } + + @Test + @DisplayName("방 생성 API - WebRTC 비활성화 테스트") + void createRoom_WithoutWebRTC() { + // given + given(currentUser.getUserId()).willReturn(1L); + + CreateRoomRequest request = new CreateRoomRequest( + "채팅 전용 방", + "텍스트만 가능", + false, + null, + 50, + false // WebRTC 비활성화 + ); + + Room chatOnlyRoom = Room.create( + "채팅 전용 방", + "텍스트만 가능", + false, + null, + 50, + testUser, + null, + false + ); + + given(roomService.createRoom( + anyString(), + anyString(), + anyBoolean(), + any(), + anyInt(), + eq(1L), + eq(false) // WebRTC false 검증 + )).willReturn(chatOnlyRoom); + + RoomResponse roomResponse = RoomResponse.from(chatOnlyRoom, 1); + given(roomService.toRoomResponse(any(Room.class))).willReturn(roomResponse); + + // when + ResponseEntity> response = roomController.createRoom(request); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getData().getAllowCamera()).isFalse(); + assertThat(response.getBody().getData().getAllowAudio()).isFalse(); + assertThat(response.getBody().getData().getAllowScreenShare()).isFalse(); + + verify(roomService, times(1)).createRoom( + anyString(), + anyString(), + anyBoolean(), + any(), + anyInt(), + eq(1L), + eq(false) + ); } } diff --git a/src/test/java/com/back/domain/studyroom/repository/RoomChatMessageRepositoryTest.java b/src/test/java/com/back/domain/studyroom/repository/RoomChatMessageRepositoryTest.java index 39852a0c..8caff59c 100644 --- a/src/test/java/com/back/domain/studyroom/repository/RoomChatMessageRepositoryTest.java +++ b/src/test/java/com/back/domain/studyroom/repository/RoomChatMessageRepositoryTest.java @@ -3,11 +3,16 @@ import com.back.domain.studyroom.entity.Room; import com.back.domain.studyroom.entity.RoomChatMessage; import com.back.domain.user.entity.User; +import com.back.global.config.DataSourceProxyTestConfig; import com.back.global.config.QueryDslTestConfig; +import com.back.global.util.QueryCounter; +import net.ttddyy.dsproxy.QueryCountHolder; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.Replace; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; import org.springframework.context.annotation.Import; @@ -23,7 +28,12 @@ @DataJpaTest @ActiveProfiles("test") -@Import({RoomChatMessageRepositoryImpl.class, QueryDslTestConfig.class}) +@AutoConfigureTestDatabase(replace = Replace.NONE) +@Import({ + RoomChatMessageRepositoryImpl.class, + QueryDslTestConfig.class, + DataSourceProxyTestConfig.class +}) @DisplayName("RoomChatMessageRepository 테스트") class RoomChatMessageRepositoryTest { @@ -66,6 +76,9 @@ void setUp() { createTestMessages(); testEntityManager.flush(); testEntityManager.clear(); + + // 쿼리 카운터 초기화 + QueryCountHolder.clear(); } private void createTestMessages() { @@ -151,23 +164,52 @@ void t3() { } @Test - @DisplayName("N+1 문제 해결 확인") + @DisplayName("N+1 문제 해결 확인 - fetch join 적용") void t4() { + // Given Pageable pageable = PageRequest.of(0, 3); - Page result = roomChatMessageRepository.findMessagesByRoomId(testRoom.getId(), pageable); + // 영속성 컨텍스트 초기화 (캐시 제거) + testEntityManager.flush(); + testEntityManager.clear(); - assertThat(result.getContent()).hasSize(3); + // 쿼리 카운터 초기화 + QueryCounter.clear(); + + // When - 1. 초기 조회 + Page result = roomChatMessageRepository + .findMessagesByRoomId(testRoom.getId(), pageable); + long selectCountAfterQuery = QueryCounter.getSelectCount(); + + // When - 2. 연관 엔티티 접근 for (RoomChatMessage message : result.getContent()) { - // 추가 쿼리 없이 접근 가능 - assertThat(message.getRoom().getTitle()).isNotNull(); - assertThat(message.getUser().getNickname()).isNotNull(); // username을 반환 + String roomTitle = message.getRoom().getTitle(); + String userNickname = message.getUser().getNickname(); - // 연관 엔티티가 제대로 로드되었는지 확인 - assertThat(message.getRoom().getTitle()).isEqualTo("테스트 스터디룸"); - assertThat(message.getUser().getNickname()).isIn("테스터1", "테스터2"); + assertThat(roomTitle).isNotNull(); + assertThat(userNickname).isNotNull(); } + + long selectCountAfterAccess = QueryCounter.getSelectCount(); + + // Then + assertThat(result.getContent()).hasSize(3); + + // fetch join이 제대로 동작하면 SELECT는 2번만 실행되어야 함 + // 1. RoomChatMessage + Room + User 조회 (fetch join) + // 2. 페이징을 위한 count 쿼리 + assertThat(selectCountAfterQuery) + .as("초기 조회 시 2번의 SELECT만 실행되어야 함 (fetch join 1번 + count 1번)") + .isEqualTo(2); + + // 연관 엔티티 접근 시에도 추가 쿼리가 발생하지 않아야 함 + assertThat(selectCountAfterAccess) + .as("연관 엔티티 접근 시 추가 쿼리가 발생하지 않아야 함") + .isEqualTo(selectCountAfterQuery); + + // 쿼리 카운트 출력 + QueryCounter.printQueryCount(); } @Test diff --git a/src/test/java/com/back/domain/studyroom/service/RoomServiceTest.java b/src/test/java/com/back/domain/studyroom/service/RoomServiceTest.java index 4ccd73dd..b0f9cb7e 100644 --- a/src/test/java/com/back/domain/studyroom/service/RoomServiceTest.java +++ b/src/test/java/com/back/domain/studyroom/service/RoomServiceTest.java @@ -11,6 +11,7 @@ import com.back.domain.user.repository.UserRepository; import com.back.global.exception.CustomException; import com.back.global.exception.ErrorCode; +import com.back.global.websocket.service.RoomParticipantService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -47,6 +48,9 @@ class RoomServiceTest { @Mock private StudyRoomProperties properties; + @Mock + private RoomParticipantService roomParticipantService; + @InjectMocks private RoomService roomService; @@ -71,7 +75,7 @@ void setUp() { userProfile.setNickname("테스트유저"); testUser.setUserProfile(userProfile); - // 테스트 방 생성 + // 테스트 방 생성 (WebRTC 사용) testRoom = Room.create( "테스트 방", "테스트 설명", @@ -79,7 +83,8 @@ void setUp() { null, 10, testUser, - null + null, + true // useWebRTC ); // 테스트 멤버 생성 @@ -101,7 +106,8 @@ void createRoom_Success() { false, null, 10, - 1L + 1L, + true // useWebRTC ); // then @@ -125,7 +131,8 @@ void createRoom_UserNotFound() { false, null, 10, - 999L + 999L, + true // useWebRTC )) .isInstanceOf(CustomException.class) .hasFieldOrPropertyWithValue("errorCode", ErrorCode.USER_NOT_FOUND); @@ -138,14 +145,16 @@ void joinRoom_Success() { given(roomRepository.findByIdWithLock(1L)).willReturn(Optional.of(testRoom)); given(userRepository.findById(2L)).willReturn(Optional.of(testUser)); given(roomMemberRepository.findByRoomIdAndUserId(1L, 2L)).willReturn(Optional.empty()); - given(roomMemberRepository.save(any(RoomMember.class))).willReturn(testMember); + given(roomParticipantService.getParticipantCount(1L)).willReturn(0L); // Redis 카운트 // when RoomMember joinedMember = roomService.joinRoom(1L, null, 2L); // then assertThat(joinedMember).isNotNull(); - verify(roomMemberRepository, times(1)).save(any(RoomMember.class)); + assertThat(joinedMember.getRole()).isEqualTo(RoomRole.VISITOR); + verify(roomParticipantService, times(1)).enterRoom(2L, 1L); // Redis 입장 확인 + verify(roomMemberRepository, never()).save(any(RoomMember.class)); // DB 저장 안됨! } @Test @@ -171,9 +180,11 @@ void joinRoom_WrongPassword() { "1234", 10, testUser, - null + null, + true // useWebRTC ); given(roomRepository.findByIdWithLock(1L)).willReturn(Optional.of(privateRoom)); + given(roomParticipantService.getParticipantCount(1L)).willReturn(0L); // Redis 카운트 // when & then assertThatThrownBy(() -> roomService.joinRoom(1L, "wrong", 1L)) @@ -185,15 +196,13 @@ void joinRoom_WrongPassword() { @DisplayName("방 나가기 - 성공") void leaveRoom_Success() { // given - testMember.updateOnlineStatus(true); given(roomRepository.findById(1L)).willReturn(Optional.of(testRoom)); - given(roomMemberRepository.findByRoomIdAndUserId(1L, 1L)).willReturn(Optional.of(testMember)); // when roomService.leaveRoom(1L, 1L); // then - verify(roomMemberRepository, times(1)).findByRoomIdAndUserId(1L, 1L); + verify(roomParticipantService, times(1)).exitRoom(1L, 1L); // Redis 퇴장 확인 } @Test @@ -242,7 +251,8 @@ void getRoomDetail_PrivateRoomForbidden() { "1234", 10, testUser, - null + null, + true // useWebRTC ); given(roomRepository.findById(1L)).willReturn(Optional.of(privateRoom)); given(roomMemberRepository.existsByRoomIdAndUserId(1L, 2L)).willReturn(false); @@ -303,7 +313,7 @@ void updateRoomSettings_NotOwner() { void terminateRoom_Success() { // given given(roomRepository.findById(1L)).willReturn(Optional.of(testRoom)); - willDoNothing().given(roomMemberRepository).disconnectAllMembers(1L); + given(roomParticipantService.getParticipants(1L)).willReturn(java.util.Set.of()); // 온라인 사용자 없음 // when roomService.terminateRoom(1L, 1L); @@ -311,7 +321,6 @@ void terminateRoom_Success() { // then assertThat(testRoom.getStatus()).isEqualTo(RoomStatus.TERMINATED); assertThat(testRoom.isActive()).isFalse(); - verify(roomMemberRepository, times(1)).disconnectAllMembers(1L); } @Test @@ -365,13 +374,12 @@ void kickMember_Success() { given(roomMemberRepository.findByRoomIdAndUserId(1L, 1L)).willReturn(Optional.of(hostMember)); given(roomMemberRepository.findByRoomIdAndUserId(1L, 2L)).willReturn(Optional.of(targetMember)); - given(roomRepository.findById(1L)).willReturn(Optional.of(testRoom)); // when roomService.kickMember(1L, 2L, 1L); // then - verify(roomMemberRepository, times(1)).findByRoomIdAndUserId(1L, 2L); + verify(roomParticipantService, times(1)).exitRoom(2L, 1L); // Redis 퇴장 확인 } @Test @@ -387,4 +395,56 @@ void kickMember_NoPermission() { .isInstanceOf(CustomException.class) .hasFieldOrPropertyWithValue("errorCode", ErrorCode.NOT_ROOM_MANAGER); } + + @Test + @DisplayName("방 생성 - WebRTC 활성화") + void createRoom_WithWebRTC() { + // given + given(userRepository.findById(1L)).willReturn(Optional.of(testUser)); + given(roomRepository.save(any(Room.class))).willAnswer(invocation -> invocation.getArgument(0)); + given(roomMemberRepository.save(any(RoomMember.class))).willReturn(testMember); + + // when + Room createdRoom = roomService.createRoom( + "WebRTC 방", + "화상 채팅 가능", + false, + null, + 10, + 1L, + true // WebRTC 사용 + ); + + // then + assertThat(createdRoom).isNotNull(); + assertThat(createdRoom.isAllowCamera()).isTrue(); + assertThat(createdRoom.isAllowAudio()).isTrue(); + assertThat(createdRoom.isAllowScreenShare()).isTrue(); + } + + @Test + @DisplayName("방 생성 - WebRTC 비활성화") + void createRoom_WithoutWebRTC() { + // given + given(userRepository.findById(1L)).willReturn(Optional.of(testUser)); + given(roomRepository.save(any(Room.class))).willAnswer(invocation -> invocation.getArgument(0)); + given(roomMemberRepository.save(any(RoomMember.class))).willReturn(testMember); + + // when + Room createdRoom = roomService.createRoom( + "채팅 전용 방", + "텍스트만 가능", + false, + null, + 50, // WebRTC 없으면 더 많은 인원 가능 + 1L, + false // WebRTC 미사용 + ); + + // then + assertThat(createdRoom).isNotNull(); + assertThat(createdRoom.isAllowCamera()).isFalse(); + assertThat(createdRoom.isAllowAudio()).isFalse(); + assertThat(createdRoom.isAllowScreenShare()).isFalse(); + } } diff --git a/src/test/java/com/back/global/config/DataSourceProxyTestConfig.java b/src/test/java/com/back/global/config/DataSourceProxyTestConfig.java new file mode 100644 index 00000000..cf7e6856 --- /dev/null +++ b/src/test/java/com/back/global/config/DataSourceProxyTestConfig.java @@ -0,0 +1,32 @@ +package com.back.global.config; + +import net.ttddyy.dsproxy.listener.logging.SLF4JLogLevel; +import net.ttddyy.dsproxy.support.ProxyDataSourceBuilder; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType; + +import javax.sql.DataSource; + +@TestConfiguration +public class DataSourceProxyTestConfig { + + @Bean + @Primary + public DataSource dataSource() { + // 실제 DataSource 생성 (테스트용 H2) + DataSource actualDataSource = new EmbeddedDatabaseBuilder() + .setType(EmbeddedDatabaseType.H2) + .build(); + + // Proxy로 감싸서 쿼리 카운팅 + return ProxyDataSourceBuilder + .create(actualDataSource) + .name("QueryCountDataSource") + .logQueryBySlf4j(SLF4JLogLevel.INFO) + .countQuery() + .build(); + } +} \ No newline at end of file diff --git a/src/test/java/com/back/global/util/QueryCounter.java b/src/test/java/com/back/global/util/QueryCounter.java new file mode 100644 index 00000000..a10c25be --- /dev/null +++ b/src/test/java/com/back/global/util/QueryCounter.java @@ -0,0 +1,30 @@ +package com.back.global.util; + +import net.ttddyy.dsproxy.QueryCount; +import net.ttddyy.dsproxy.QueryCountHolder; + +public class QueryCounter { + + public static void clear() { + QueryCountHolder.clear(); + } + + public static long getSelectCount() { + return QueryCountHolder.getGrandTotal().getSelect(); + } + + public static long getTotalCount() { + return QueryCountHolder.getGrandTotal().getTotal(); + } + + public static void printQueryCount() { + QueryCount queryCount = QueryCountHolder.getGrandTotal(); + System.out.println("\n========== Query Count =========="); + System.out.println("SELECT: " + queryCount.getSelect()); + System.out.println("INSERT: " + queryCount.getInsert()); + System.out.println("UPDATE: " + queryCount.getUpdate()); + System.out.println("DELETE: " + queryCount.getDelete()); + System.out.println("TOTAL: " + queryCount.getTotal()); + System.out.println("=================================\n"); + } +} \ No newline at end of file diff --git a/src/test/java/com/back/global/websocket/controller/WebSocketApiControllerTest.java b/src/test/java/com/back/global/websocket/controller/WebSocketApiControllerTest.java index 566847c7..a75de320 100644 --- a/src/test/java/com/back/global/websocket/controller/WebSocketApiControllerTest.java +++ b/src/test/java/com/back/global/websocket/controller/WebSocketApiControllerTest.java @@ -58,13 +58,15 @@ void t1() throws Exception { .andExpect(jsonPath("$.data.service").value("WebSocket")) .andExpect(jsonPath("$.data.status").value("running")) .andExpect(jsonPath("$.data.timestamp").exists()) - .andExpect(jsonPath("$.data.sessionTTL").value("10분 (Heartbeat 방식)")) - .andExpect(jsonPath("$.data.heartbeatInterval").value("5분")) + .andExpect(jsonPath("$.data.sessionTTL").exists()) + .andExpect(jsonPath("$.data.heartbeatInterval").exists()) .andExpect(jsonPath("$.data.totalOnlineUsers").value(totalOnlineUsers)) .andExpect(jsonPath("$.data.endpoints").exists()) .andExpect(jsonPath("$.data.endpoints.websocket").value("/ws")) .andExpect(jsonPath("$.data.endpoints.heartbeat").value("/app/heartbeat")) - .andExpect(jsonPath("$.data.endpoints.activity").value("/app/activity")); + .andExpect(jsonPath("$.data.endpoints.activity").value("/app/activity")) + .andExpect(jsonPath("$.data.endpoints.joinRoom").value("/app/rooms/{roomId}/join")) + .andExpect(jsonPath("$.data.endpoints.leaveRoom").value("/app/rooms/{roomId}/leave")); verify(sessionManager).getTotalOnlineUserCount(); } @@ -156,8 +158,8 @@ void t6() throws Exception { .andExpect(jsonPath("$.data.websocketUrl").value("/ws")) .andExpect(jsonPath("$.data.sockjsSupport").value(true)) .andExpect(jsonPath("$.data.stompVersion").value("1.2")) - .andExpect(jsonPath("$.data.heartbeatInterval").value("5분")) - .andExpect(jsonPath("$.data.sessionTTL").value("10분")); + .andExpect(jsonPath("$.data.heartbeatInterval").exists()) + .andExpect(jsonPath("$.data.sessionTTL").exists()); } @Test diff --git a/src/test/java/com/back/global/websocket/service/RoomParticipantServiceTest.java b/src/test/java/com/back/global/websocket/service/RoomParticipantServiceTest.java new file mode 100644 index 00000000..50ed4caf --- /dev/null +++ b/src/test/java/com/back/global/websocket/service/RoomParticipantServiceTest.java @@ -0,0 +1,441 @@ +package com.back.global.websocket.service; + +import com.back.global.exception.CustomException; +import com.back.global.exception.ErrorCode; +import com.back.global.websocket.dto.WebSocketSessionInfo; +import com.back.global.websocket.store.RedisSessionStore; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +@DisplayName("RoomParticipantService 단위 테스트") +class RoomParticipantServiceTest { + + @Mock + private RedisSessionStore redisSessionStore; + + @InjectMocks + private RoomParticipantService roomParticipantService; + + private Long userId; + private String sessionId; + private Long roomId; + private WebSocketSessionInfo sessionInfo; + + @BeforeEach + void setUp() { + userId = 1L; + sessionId = "test-session-123"; + roomId = 100L; + sessionInfo = WebSocketSessionInfo.createNewSession(userId, sessionId); + } + + @Test + @DisplayName("방 입장 - 정상 케이스 (첫 입장)") + void t1() { + // given + given(redisSessionStore.getUserSession(userId)).willReturn(sessionInfo); + + // when + roomParticipantService.enterRoom(userId, roomId); + + // then + ArgumentCaptor sessionCaptor = ArgumentCaptor.forClass(WebSocketSessionInfo.class); + verify(redisSessionStore).saveUserSession(eq(userId), sessionCaptor.capture()); + verify(redisSessionStore).addUserToRoom(roomId, userId); + + WebSocketSessionInfo updatedSession = sessionCaptor.getValue(); + assertThat(updatedSession.currentRoomId()).isEqualTo(roomId); + assertThat(updatedSession.userId()).isEqualTo(userId); + } + + @Test + @DisplayName("방 입장 - 기존 방에서 자동 퇴장 후 새 방 입장") + void t2() { + // given + Long oldRoomId = 200L; + WebSocketSessionInfo sessionWithOldRoom = sessionInfo.withRoomId(oldRoomId); + given(redisSessionStore.getUserSession(userId)) + .willReturn(sessionWithOldRoom) // 첫 번째 호출 (입장 시) + .willReturn(sessionWithOldRoom); // 두 번째 호출 (퇴장 시) + + // when + roomParticipantService.enterRoom(userId, roomId); + + // then + // 기존 방 퇴장 확인 + verify(redisSessionStore).removeUserFromRoom(oldRoomId, userId); + + // 새 방 입장 확인 + verify(redisSessionStore).addUserToRoom(roomId, userId); + + // 세션 업데이트 2번 (퇴장 시 1번, 입장 시 1번) + ArgumentCaptor sessionCaptor = ArgumentCaptor.forClass(WebSocketSessionInfo.class); + verify(redisSessionStore, times(2)).saveUserSession(eq(userId), sessionCaptor.capture()); + + WebSocketSessionInfo finalSession = sessionCaptor.getAllValues().get(1); + assertThat(finalSession.currentRoomId()).isEqualTo(roomId); + } + + @Test + @DisplayName("방 입장 - 세션 정보 없음 (예외 발생)") + void t3() { + // given + given(redisSessionStore.getUserSession(userId)).willReturn(null); + + // when & then + assertThatThrownBy(() -> roomParticipantService.enterRoom(userId, roomId)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.WS_SESSION_NOT_FOUND); + + verify(redisSessionStore, never()).addUserToRoom(anyLong(), anyLong()); + verify(redisSessionStore, never()).saveUserSession(anyLong(), any()); + } + + @Test + @DisplayName("방 퇴장 - 정상 케이스") + void t4() { + // given + WebSocketSessionInfo sessionWithRoom = sessionInfo.withRoomId(roomId); + given(redisSessionStore.getUserSession(userId)).willReturn(sessionWithRoom); + + // when + roomParticipantService.exitRoom(userId, roomId); + + // then + ArgumentCaptor sessionCaptor = ArgumentCaptor.forClass(WebSocketSessionInfo.class); + verify(redisSessionStore).saveUserSession(eq(userId), sessionCaptor.capture()); + verify(redisSessionStore).removeUserFromRoom(roomId, userId); + + WebSocketSessionInfo updatedSession = sessionCaptor.getValue(); + assertThat(updatedSession.currentRoomId()).isNull(); + } + + @Test + @DisplayName("방 퇴장 - 세션 정보 없음 (퇴장 처리는 계속 진행)") + void t5() { + // given + given(redisSessionStore.getUserSession(userId)).willReturn(null); + + // when + roomParticipantService.exitRoom(userId, roomId); + + // then + verify(redisSessionStore).removeUserFromRoom(roomId, userId); + verify(redisSessionStore, never()).saveUserSession(anyLong(), any()); + } + + @Test + @DisplayName("현재 방 ID 조회 - 방 있음") + void t6() { + // given + WebSocketSessionInfo sessionWithRoom = sessionInfo.withRoomId(roomId); + given(redisSessionStore.getUserSession(userId)).willReturn(sessionWithRoom); + + // when + Long result = roomParticipantService.getCurrentRoomId(userId); + + // then + assertThat(result).isEqualTo(roomId); + verify(redisSessionStore).getUserSession(userId); + } + + @Test + @DisplayName("현재 방 ID 조회 - 방 없음") + void t7() { + // given + given(redisSessionStore.getUserSession(userId)).willReturn(sessionInfo); + + // when + Long result = roomParticipantService.getCurrentRoomId(userId); + + // then + assertThat(result).isNull(); + verify(redisSessionStore).getUserSession(userId); + } + + @Test + @DisplayName("현재 방 ID 조회 - 세션 없음") + void t8() { + // given + given(redisSessionStore.getUserSession(userId)).willReturn(null); + + // when + Long result = roomParticipantService.getCurrentRoomId(userId); + + // then + assertThat(result).isNull(); + verify(redisSessionStore).getUserSession(userId); + } + + @Test + @DisplayName("방의 참가자 목록 조회") + void t9() { + // given + Set expectedParticipants = Set.of(1L, 2L, 3L); + given(redisSessionStore.getRoomUsers(roomId)).willReturn(expectedParticipants); + + // when + Set result = roomParticipantService.getParticipants(roomId); + + // then + assertThat(result).containsExactlyInAnyOrderElementsOf(expectedParticipants); + verify(redisSessionStore).getRoomUsers(roomId); + } + + @Test + @DisplayName("방의 참가자 목록 조회 - 빈 방") + void t10() { + // given + given(redisSessionStore.getRoomUsers(roomId)).willReturn(Set.of()); + + // when + Set result = roomParticipantService.getParticipants(roomId); + + // then + assertThat(result).isEmpty(); + verify(redisSessionStore).getRoomUsers(roomId); + } + + @Test + @DisplayName("방의 참가자 수 조회") + void t11() { + // given + long expectedCount = 5L; + given(redisSessionStore.getRoomUserCount(roomId)).willReturn(expectedCount); + + // when + long result = roomParticipantService.getParticipantCount(roomId); + + // then + assertThat(result).isEqualTo(expectedCount); + verify(redisSessionStore).getRoomUserCount(roomId); + } + + @Test + @DisplayName("방의 참가자 수 조회 - 빈 방") + void t12() { + // given + given(redisSessionStore.getRoomUserCount(roomId)).willReturn(0L); + + // when + long result = roomParticipantService.getParticipantCount(roomId); + + // then + assertThat(result).isZero(); + verify(redisSessionStore).getRoomUserCount(roomId); + } + + @Test + @DisplayName("사용자가 특정 방에 참여 중인지 확인 - 참여 중") + void t13() { + // given + WebSocketSessionInfo sessionWithRoom = sessionInfo.withRoomId(roomId); + given(redisSessionStore.getUserSession(userId)).willReturn(sessionWithRoom); + + // when + boolean result = roomParticipantService.isUserInRoom(userId, roomId); + + // then + assertThat(result).isTrue(); + verify(redisSessionStore).getUserSession(userId); + } + + @Test + @DisplayName("사용자가 특정 방에 참여 중인지 확인 - 다른 방에 참여 중") + void t14() { + // given + Long differentRoomId = 999L; + WebSocketSessionInfo sessionWithDifferentRoom = sessionInfo.withRoomId(differentRoomId); + given(redisSessionStore.getUserSession(userId)).willReturn(sessionWithDifferentRoom); + + // when + boolean result = roomParticipantService.isUserInRoom(userId, roomId); + + // then + assertThat(result).isFalse(); + verify(redisSessionStore).getUserSession(userId); + } + + @Test + @DisplayName("사용자가 특정 방에 참여 중인지 확인 - 어떤 방에도 없음") + void t15() { + // given + given(redisSessionStore.getUserSession(userId)).willReturn(sessionInfo); + + // when + boolean result = roomParticipantService.isUserInRoom(userId, roomId); + + // then + assertThat(result).isFalse(); + verify(redisSessionStore).getUserSession(userId); + } + + @Test + @DisplayName("사용자가 특정 방에 참여 중인지 확인 - 세션 없음") + void t16() { + // given + given(redisSessionStore.getUserSession(userId)).willReturn(null); + + // when + boolean result = roomParticipantService.isUserInRoom(userId, roomId); + + // then + assertThat(result).isFalse(); + verify(redisSessionStore).getUserSession(userId); + } + + @Test + @DisplayName("모든 방에서 퇴장 - 현재 방 있음") + void t17() { + // given + WebSocketSessionInfo sessionWithRoom = sessionInfo.withRoomId(roomId); + given(redisSessionStore.getUserSession(userId)).willReturn(sessionWithRoom); + + // when + roomParticipantService.exitAllRooms(userId); + + // then + verify(redisSessionStore, times(2)).getUserSession(userId); + verify(redisSessionStore).removeUserFromRoom(roomId, userId); + verify(redisSessionStore).saveUserSession(eq(userId), any(WebSocketSessionInfo.class)); + } + + @Test + @DisplayName("모든 방에서 퇴장 - 현재 방 없음") + void t18() { + // given + given(redisSessionStore.getUserSession(userId)).willReturn(sessionInfo); + + // when + roomParticipantService.exitAllRooms(userId); + + // then + verify(redisSessionStore).getUserSession(userId); + verify(redisSessionStore, never()).removeUserFromRoom(anyLong(), anyLong()); + verify(redisSessionStore, never()).saveUserSession(anyLong(), any()); + } + + @Test + @DisplayName("모든 방에서 퇴장 - 세션 없음") + void t19() { + // given + given(redisSessionStore.getUserSession(userId)).willReturn(null); + + // when + roomParticipantService.exitAllRooms(userId); + + // then + verify(redisSessionStore).getUserSession(userId); + verify(redisSessionStore, never()).removeUserFromRoom(anyLong(), anyLong()); + } + + @Test + @DisplayName("모든 방에서 퇴장 - 예외 발생해도 에러를 던지지 않음") + void t20() { + // given + given(redisSessionStore.getUserSession(userId)) + .willThrow(new RuntimeException("Redis connection failed")); + + // when & then - 예외가 발생해도 메서드는 정상 종료되어야 함 + assertThatCode(() -> roomParticipantService.exitAllRooms(userId)) + .doesNotThrowAnyException(); + + verify(redisSessionStore).getUserSession(userId); + } + + @Test + @DisplayName("같은 방에 재입장 시도") + void t21() { + // given + WebSocketSessionInfo sessionWithRoom = sessionInfo.withRoomId(roomId); + given(redisSessionStore.getUserSession(userId)).willReturn(sessionWithRoom); + + // when + roomParticipantService.enterRoom(userId, roomId); + + // then + // 같은 방이므로 퇴장 처리가 발생함 + verify(redisSessionStore).removeUserFromRoom(roomId, userId); + verify(redisSessionStore).addUserToRoom(roomId, userId); + + // 세션 업데이트는 2번 (퇴장 + 입장) + verify(redisSessionStore, times(2)).saveUserSession(eq(userId), any(WebSocketSessionInfo.class)); + } + + @Test + @DisplayName("방 A → 방 B → 방 C 연속 이동") + void t22() { + // given + Long roomA = 100L; + Long roomB = 200L; + Long roomC = 300L; + + WebSocketSessionInfo session1 = sessionInfo; + WebSocketSessionInfo sessionInA = session1.withRoomId(roomA); + WebSocketSessionInfo sessionInB = sessionInA.withRoomId(roomB); + + given(redisSessionStore.getUserSession(userId)) + .willReturn(session1) // 첫 번째 입장 (방 A) + .willReturn(sessionInA) // 두 번째 입장 (방 B) - 기존 방 A에서 퇴장 + .willReturn(sessionInA) // 방 A 퇴장 처리 + .willReturn(sessionInB) // 세 번째 입장 (방 C) - 기존 방 B에서 퇴장 + .willReturn(sessionInB); // 방 B 퇴장 처리 + + // when + roomParticipantService.enterRoom(userId, roomA); + roomParticipantService.enterRoom(userId, roomB); + roomParticipantService.enterRoom(userId, roomC); + + // then + verify(redisSessionStore).addUserToRoom(roomA, userId); + verify(redisSessionStore).addUserToRoom(roomB, userId); + verify(redisSessionStore).addUserToRoom(roomC, userId); + + verify(redisSessionStore).removeUserFromRoom(roomA, userId); + verify(redisSessionStore).removeUserFromRoom(roomB, userId); + } + + @Test + @DisplayName("방 입장 후 명시적 퇴장") + void t23() { + // given + WebSocketSessionInfo sessionWithoutRoom = sessionInfo; + WebSocketSessionInfo sessionWithRoom = sessionInfo.withRoomId(roomId); + + given(redisSessionStore.getUserSession(userId)) + .willReturn(sessionWithoutRoom) // 입장 시 + .willReturn(sessionWithRoom); // 퇴장 시 + + // when + roomParticipantService.enterRoom(userId, roomId); + roomParticipantService.exitRoom(userId, roomId); + + // then + verify(redisSessionStore).addUserToRoom(roomId, userId); + verify(redisSessionStore).removeUserFromRoom(roomId, userId); + + ArgumentCaptor captor = ArgumentCaptor.forClass(WebSocketSessionInfo.class); + verify(redisSessionStore, times(2)).saveUserSession(eq(userId), captor.capture()); + + // 입장 시 방 ID가 설정됨 + assertThat(captor.getAllValues().get(0).currentRoomId()).isEqualTo(roomId); + // 퇴장 시 방 ID가 null이 됨 + assertThat(captor.getAllValues().get(1).currentRoomId()).isNull(); + } +} \ No newline at end of file diff --git a/src/test/java/com/back/global/websocket/service/UserSessionServiceTest.java b/src/test/java/com/back/global/websocket/service/UserSessionServiceTest.java new file mode 100644 index 00000000..e853e87b --- /dev/null +++ b/src/test/java/com/back/global/websocket/service/UserSessionServiceTest.java @@ -0,0 +1,337 @@ +package com.back.global.websocket.service; + +import com.back.global.websocket.dto.WebSocketSessionInfo; +import com.back.global.websocket.store.RedisSessionStore; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +@DisplayName("UserSessionService 단위 테스트") +class UserSessionServiceTest { + + @Mock + private RedisSessionStore redisSessionStore; + + @InjectMocks + private UserSessionService userSessionService; + + private Long userId; + private String sessionId; + private WebSocketSessionInfo sessionInfo; + + @BeforeEach + void setUp() { + userId = 1L; + sessionId = "test-session-123"; + sessionInfo = WebSocketSessionInfo.createNewSession(userId, sessionId); + } + + @Test + @DisplayName("새 세션 등록 - 기존 세션 없음") + void t1() { + // given + given(redisSessionStore.getUserSession(userId)).willReturn(null); + + // when + userSessionService.registerSession(userId, sessionId); + + // then + ArgumentCaptor sessionCaptor = ArgumentCaptor.forClass(WebSocketSessionInfo.class); + verify(redisSessionStore).saveUserSession(eq(userId), sessionCaptor.capture()); + verify(redisSessionStore).saveSessionUserMapping(eq(sessionId), eq(userId)); + + WebSocketSessionInfo savedSession = sessionCaptor.getValue(); + assertThat(savedSession.userId()).isEqualTo(userId); + assertThat(savedSession.sessionId()).isEqualTo(sessionId); + assertThat(savedSession.currentRoomId()).isNull(); + assertThat(savedSession.connectedAt()).isNotNull(); + assertThat(savedSession.lastActiveAt()).isNotNull(); + } + + @Test + @DisplayName("새 세션 등록 - 기존 세션 있음 (기존 세션 종료 후 등록)") + void t2() { + // given + String oldSessionId = "old-session-456"; + WebSocketSessionInfo oldSession = WebSocketSessionInfo.createNewSession(userId, oldSessionId); + given(redisSessionStore.getUserSession(userId)).willReturn(oldSession); + given(redisSessionStore.getUserIdBySession(oldSessionId)).willReturn(userId); + + // when + userSessionService.registerSession(userId, sessionId); + + // then + // 기존 세션 종료 확인 + verify(redisSessionStore).getUserIdBySession(oldSessionId); + verify(redisSessionStore).deleteUserSession(userId); + verify(redisSessionStore).deleteSessionUserMapping(oldSessionId); + + // 새 세션 등록 확인 + verify(redisSessionStore, times(1)).saveUserSession(eq(userId), any(WebSocketSessionInfo.class)); + verify(redisSessionStore).saveSessionUserMapping(eq(sessionId), eq(userId)); + } + + @Test + @DisplayName("세션 종료 - 정상 케이스") + void t3() { + // given + given(redisSessionStore.getUserIdBySession(sessionId)).willReturn(userId); + + // when + userSessionService.terminateSession(sessionId); + + // then + verify(redisSessionStore).deleteUserSession(userId); + verify(redisSessionStore).deleteSessionUserMapping(sessionId); + } + + @Test + @DisplayName("세션 종료 - 존재하지 않는 세션") + void t4() { + // given + given(redisSessionStore.getUserIdBySession(sessionId)).willReturn(null); + + // when + userSessionService.terminateSession(sessionId); + + // then + verify(redisSessionStore, never()).deleteUserSession(anyLong()); + verify(redisSessionStore, never()).deleteSessionUserMapping(anyString()); + } + + @Test + @DisplayName("Heartbeat 처리 - 정상 케이스") + void t5() { + // given + given(redisSessionStore.getUserSession(userId)).willReturn(sessionInfo); + + // when + userSessionService.processHeartbeat(userId); + + // then + ArgumentCaptor sessionCaptor = ArgumentCaptor.forClass(WebSocketSessionInfo.class); + verify(redisSessionStore).saveUserSession(eq(userId), sessionCaptor.capture()); + + WebSocketSessionInfo updatedSession = sessionCaptor.getValue(); + assertThat(updatedSession.userId()).isEqualTo(userId); + assertThat(updatedSession.sessionId()).isEqualTo(sessionId); + // lastActiveAt이 업데이트되었는지 확인 + assertThat(updatedSession.lastActiveAt()).isAfterOrEqualTo(sessionInfo.lastActiveAt()); + } + + @Test + @DisplayName("Heartbeat 처리 - 세션 정보 없음") + void t6() { + // given + given(redisSessionStore.getUserSession(userId)).willReturn(null); + + // when + userSessionService.processHeartbeat(userId); + + // then + verify(redisSessionStore, never()).saveUserSession(anyLong(), any()); + } + + @Test + @DisplayName("사용자 연결 상태 확인 - 연결됨") + void t7() { + // given + given(redisSessionStore.existsUserSession(userId)).willReturn(true); + + // when + boolean result = userSessionService.isConnected(userId); + + // then + assertThat(result).isTrue(); + verify(redisSessionStore).existsUserSession(userId); + } + + @Test + @DisplayName("사용자 연결 상태 확인 - 연결 안 됨") + void t8() { + // given + given(redisSessionStore.existsUserSession(userId)).willReturn(false); + + // when + boolean result = userSessionService.isConnected(userId); + + // then + assertThat(result).isFalse(); + verify(redisSessionStore).existsUserSession(userId); + } + + @Test + @DisplayName("세션 정보 조회 - 정상 케이스") + void t9() { + // given + given(redisSessionStore.getUserSession(userId)).willReturn(sessionInfo); + + // when + WebSocketSessionInfo result = userSessionService.getSessionInfo(userId); + + // then + assertThat(result).isNotNull(); + assertThat(result.userId()).isEqualTo(userId); + assertThat(result.sessionId()).isEqualTo(sessionId); + verify(redisSessionStore).getUserSession(userId); + } + + @Test + @DisplayName("세션 정보 조회 - 세션 없음") + void t10() { + // given + given(redisSessionStore.getUserSession(userId)).willReturn(null); + + // when + WebSocketSessionInfo result = userSessionService.getSessionInfo(userId); + + // then + assertThat(result).isNull(); + verify(redisSessionStore).getUserSession(userId); + } + + @Test + @DisplayName("세션ID로 사용자ID 조회 - 정상 케이스") + void t11() { + // given + given(redisSessionStore.getUserIdBySession(sessionId)).willReturn(userId); + + // when + Long result = userSessionService.getUserIdBySessionId(sessionId); + + // then + assertThat(result).isEqualTo(userId); + verify(redisSessionStore).getUserIdBySession(sessionId); + } + + @Test + @DisplayName("세션ID로 사용자ID 조회 - 세션 없음") + void t12() { + // given + given(redisSessionStore.getUserIdBySession(sessionId)).willReturn(null); + + // when + Long result = userSessionService.getUserIdBySessionId(sessionId); + + // then + assertThat(result).isNull(); + verify(redisSessionStore).getUserIdBySession(sessionId); + } + + @Test + @DisplayName("사용자의 현재 방 ID 조회 - 방 있음") + void t13() { + // given + Long roomId = 100L; + WebSocketSessionInfo sessionWithRoom = sessionInfo.withRoomId(roomId); + given(redisSessionStore.getUserSession(userId)).willReturn(sessionWithRoom); + + // when + Long result = userSessionService.getCurrentRoomId(userId); + + // then + assertThat(result).isEqualTo(roomId); + verify(redisSessionStore).getUserSession(userId); + } + + @Test + @DisplayName("사용자의 현재 방 ID 조회 - 방 없음") + void t14() { + // given + given(redisSessionStore.getUserSession(userId)).willReturn(sessionInfo); + + // when + Long result = userSessionService.getCurrentRoomId(userId); + + // then + assertThat(result).isNull(); + verify(redisSessionStore).getUserSession(userId); + } + + @Test + @DisplayName("사용자의 현재 방 ID 조회 - 세션 없음") + void t15() { + // given + given(redisSessionStore.getUserSession(userId)).willReturn(null); + + // when + Long result = userSessionService.getCurrentRoomId(userId); + + // then + assertThat(result).isNull(); + verify(redisSessionStore).getUserSession(userId); + } + + @Test + @DisplayName("전체 온라인 사용자 수 조회") + void t16() { + // given + long expectedCount = 42L; + given(redisSessionStore.getTotalOnlineUserCount()).willReturn(expectedCount); + + // when + long result = userSessionService.getTotalOnlineUserCount(); + + // then + assertThat(result).isEqualTo(expectedCount); + verify(redisSessionStore).getTotalOnlineUserCount(); + } + + @Test + @DisplayName("전체 온라인 사용자 수 조회 - 사용자 없음") + void t17() { + // given + given(redisSessionStore.getTotalOnlineUserCount()).willReturn(0L); + + // when + long result = userSessionService.getTotalOnlineUserCount(); + + // then + assertThat(result).isZero(); + verify(redisSessionStore).getTotalOnlineUserCount(); + } + + @Test + @DisplayName("중복 세션 등록 시 기존 세션이 완전히 정리됨") + void t18() { + // given + String oldSessionId = "old-session"; + Long oldRoomId = 999L; + WebSocketSessionInfo oldSession = WebSocketSessionInfo.createNewSession(userId, oldSessionId) + .withRoomId(oldRoomId); + + given(redisSessionStore.getUserSession(userId)).willReturn(oldSession); + given(redisSessionStore.getUserIdBySession(oldSessionId)).willReturn(userId); + + // when + userSessionService.registerSession(userId, sessionId); + + // then + // 기존 세션 정리 검증 + verify(redisSessionStore).getUserIdBySession(oldSessionId); + verify(redisSessionStore).deleteUserSession(userId); + verify(redisSessionStore).deleteSessionUserMapping(oldSessionId); + + // 새 세션 생성 검증 + ArgumentCaptor captor = ArgumentCaptor.forClass(WebSocketSessionInfo.class); + verify(redisSessionStore).saveUserSession(eq(userId), captor.capture()); + verify(redisSessionStore).saveSessionUserMapping(eq(sessionId), eq(userId)); + + WebSocketSessionInfo newSession = captor.getValue(); + assertThat(newSession.sessionId()).isEqualTo(sessionId); + assertThat(newSession.currentRoomId()).isNull(); // 새 세션은 방 정보 없음 + } +} \ No newline at end of file diff --git a/src/test/java/com/back/global/websocket/service/WebSocketSessionManagerTest.java b/src/test/java/com/back/global/websocket/service/WebSocketSessionManagerTest.java index 297a2e2e..380cec49 100644 --- a/src/test/java/com/back/global/websocket/service/WebSocketSessionManagerTest.java +++ b/src/test/java/com/back/global/websocket/service/WebSocketSessionManagerTest.java @@ -10,453 +10,512 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.data.redis.core.SetOperations; -import org.springframework.data.redis.core.ValueOperations; -import java.time.Duration; import java.util.Set; import static org.assertj.core.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; +import static org.mockito.BDDMockito.*; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.never; @ExtendWith(MockitoExtension.class) -@DisplayName("WebSocketSessionManager 테스트") +@DisplayName("WebSocketSessionManager 단위 테스트") class WebSocketSessionManagerTest { @Mock - private RedisTemplate redisTemplate; + private UserSessionService userSessionService; @Mock - private ValueOperations valueOperations; - - @Mock - private SetOperations setOperations; + private RoomParticipantService roomParticipantService; @InjectMocks private WebSocketSessionManager sessionManager; - private final Long TEST_USER_ID = 123L; - private final String TEST_SESSION_ID = "session-123"; - private final Long TEST_ROOM_ID = 456L; + private Long userId; + private String sessionId; + private Long roomId; @BeforeEach void setUp() { - // Mock 설정은 각 테스트에서 필요할 때만 수행 + userId = 1L; + sessionId = "test-session-123"; + roomId = 100L; } @Test - @DisplayName("사용자 세션 등록 - 성공") - void t1() { - // given - when(redisTemplate.opsForValue()).thenReturn(valueOperations); - when(valueOperations.get(anyString())).thenReturn(null); // 기존 세션 없음 - + @DisplayName("세션 추가") + void addSession() { // when - assertThatNoException().isThrownBy(() -> - sessionManager.addSession(TEST_USER_ID, TEST_SESSION_ID) - ); + sessionManager.addSession(userId, sessionId); // then - verify(valueOperations, times(2)).set(anyString(), any(), eq(Duration.ofMinutes(6))); + verify(userSessionService).registerSession(userId, sessionId); } @Test - @DisplayName("사용자 세션 등록 - 기존 세션 있을 때 제거 후 등록") - void t2() { + @DisplayName("세션 제거 - 정상 케이스") + void removeSession_Success() { // given - when(redisTemplate.opsForValue()).thenReturn(valueOperations); - - WebSocketSessionInfo existingSession = WebSocketSessionInfo.createNewSession(TEST_USER_ID, "old-session-123") - .withUpdatedActivity(); // 활동 시간 업데이트 + given(userSessionService.getUserIdBySessionId(sessionId)).willReturn(userId); // when - when(valueOperations.get("ws:user:123")).thenReturn(existingSession); - when(valueOperations.get("ws:session:old-session-123")).thenReturn(TEST_USER_ID); - - assertThatNoException().isThrownBy(() -> - sessionManager.addSession(TEST_USER_ID, TEST_SESSION_ID) - ); + sessionManager.removeSession(sessionId); // then - verify(redisTemplate, atLeastOnce()).delete(anyString()); // 기존 세션 삭제 - verify(valueOperations, times(2)).set(anyString(), any(), eq(Duration.ofMinutes(6))); // 새 세션 등록 + verify(userSessionService).getUserIdBySessionId(sessionId); + verify(roomParticipantService).exitAllRooms(userId); + verify(userSessionService).terminateSession(sessionId); } @Test - @DisplayName("사용자 세션 등록 - Redis 오류 시 예외 발생") - void t3() { + @DisplayName("세션 제거 - 존재하지 않는 세션") + void removeSession_NotFound() { // given - when(redisTemplate.opsForValue()).thenReturn(valueOperations); - when(valueOperations.get(anyString())).thenThrow(new RuntimeException("Redis connection failed")); + given(userSessionService.getUserIdBySessionId(sessionId)).willReturn(null); - // when & then - assertThatThrownBy(() -> sessionManager.addSession(TEST_USER_ID, TEST_SESSION_ID)) - .isInstanceOf(CustomException.class) - .hasFieldOrPropertyWithValue("errorCode", ErrorCode.WS_CONNECTION_FAILED); + // when + sessionManager.removeSession(sessionId); + + // then + verify(userSessionService).getUserIdBySessionId(sessionId); + verify(roomParticipantService, never()).exitAllRooms(anyLong()); + verify(userSessionService, never()).terminateSession(anyString()); } @Test - @DisplayName("사용자 연결 상태 확인 - 연결됨") - void t4() { + @DisplayName("사용자 연결 상태 확인") + void isUserConnected() { // given - when(redisTemplate.hasKey("ws:user:123")).thenReturn(true); + given(userSessionService.isConnected(userId)).willReturn(true); // when - boolean result = sessionManager.isUserConnected(TEST_USER_ID); + boolean result = sessionManager.isUserConnected(userId); // then assertThat(result).isTrue(); - verify(redisTemplate).hasKey("ws:user:123"); + verify(userSessionService).isConnected(userId); } @Test - @DisplayName("사용자 연결 상태 확인 - 연결되지 않음") - void t5() { + @DisplayName("사용자 세션 정보 조회") + void getSessionInfo() { // given - when(redisTemplate.hasKey("ws:user:123")).thenReturn(false); + WebSocketSessionInfo sessionInfo = WebSocketSessionInfo.createNewSession(userId, sessionId); + given(userSessionService.getSessionInfo(userId)).willReturn(sessionInfo); // when - boolean result = sessionManager.isUserConnected(TEST_USER_ID); + WebSocketSessionInfo result = sessionManager.getSessionInfo(userId); // then - assertThat(result).isFalse(); - verify(redisTemplate).hasKey("ws:user:123"); + assertThat(result).isNotNull(); + assertThat(result.userId()).isEqualTo(userId); + verify(userSessionService).getSessionInfo(userId); } @Test - @DisplayName("사용자 연결 상태 확인 - Redis 오류 시 예외 발생") - void t6() { - // given - when(redisTemplate.hasKey(anyString())).thenThrow(new RuntimeException("Redis error")); + @DisplayName("Heartbeat 처리") + void updateLastActivity() { + // when + sessionManager.updateLastActivity(userId); - // when & then - assertThatThrownBy(() -> sessionManager.isUserConnected(TEST_USER_ID)) - .isInstanceOf(CustomException.class) - .hasFieldOrPropertyWithValue("errorCode", ErrorCode.WS_REDIS_ERROR); + // then + verify(userSessionService).processHeartbeat(userId); } @Test - @DisplayName("세션 정보 조회 - 성공") - void t7() { + @DisplayName("전체 온라인 사용자 수 조회") + void getTotalOnlineUserCount() { // given - when(redisTemplate.opsForValue()).thenReturn(valueOperations); + long expectedCount = 42L; + given(userSessionService.getTotalOnlineUserCount()).willReturn(expectedCount); - // 체이닝으로 세션 정보 생성 - WebSocketSessionInfo expectedSessionInfo = WebSocketSessionInfo - .createNewSession(TEST_USER_ID, TEST_SESSION_ID) - .withRoomId(TEST_ROOM_ID); + // when + long result = sessionManager.getTotalOnlineUserCount(); - when(valueOperations.get("ws:user:123")).thenReturn(expectedSessionInfo); + // then + assertThat(result).isEqualTo(expectedCount); + verify(userSessionService).getTotalOnlineUserCount(); + } + @Test + @DisplayName("방 입장") + void joinRoom() { // when - WebSocketSessionInfo result = sessionManager.getSessionInfo(TEST_USER_ID); + sessionManager.joinRoom(userId, roomId); // then - assertThat(result).isNotNull(); - assertThat(result.userId()).isEqualTo(TEST_USER_ID); - assertThat(result.sessionId()).isEqualTo(TEST_SESSION_ID); - assertThat(result.currentRoomId()).isEqualTo(TEST_ROOM_ID); + verify(roomParticipantService).enterRoom(userId, roomId); } @Test - @DisplayName("세션 정보 조회 - 세션이 없음") - void t8() { - // given - when(redisTemplate.opsForValue()).thenReturn(valueOperations); - when(valueOperations.get("ws:user:123")).thenReturn(null); - + @DisplayName("방 퇴장") + void leaveRoom() { // when - WebSocketSessionInfo result = sessionManager.getSessionInfo(TEST_USER_ID); + sessionManager.leaveRoom(userId, roomId); // then - assertThat(result).isNull(); + verify(roomParticipantService).exitRoom(userId, roomId); } @Test - @DisplayName("활동 시간 업데이트 - 성공") - void t9() { + @DisplayName("방의 온라인 사용자 수 조회") + void getRoomOnlineUserCount() { // given - when(redisTemplate.opsForValue()).thenReturn(valueOperations); - - WebSocketSessionInfo sessionInfo = WebSocketSessionInfo - .createNewSession(TEST_USER_ID, TEST_SESSION_ID); - - when(valueOperations.get("ws:user:123")).thenReturn(sessionInfo); + long expectedCount = 10L; + given(roomParticipantService.getParticipantCount(roomId)).willReturn(expectedCount); // when - assertThatNoException().isThrownBy(() -> - sessionManager.updateLastActivity(TEST_USER_ID) - ); + long result = sessionManager.getRoomOnlineUserCount(roomId); // then - verify(valueOperations).set(eq("ws:user:123"), any(WebSocketSessionInfo.class), eq(Duration.ofMinutes(6))); + assertThat(result).isEqualTo(expectedCount); + verify(roomParticipantService).getParticipantCount(roomId); } @Test - @DisplayName("활동 시간 업데이트 - 세션이 없을 때") - void t10() { + @DisplayName("방의 온라인 사용자 목록 조회") + void getOnlineUsersInRoom() { // given - when(redisTemplate.opsForValue()).thenReturn(valueOperations); - when(valueOperations.get("ws:user:123")).thenReturn(null); + Set expectedUsers = Set.of(1L, 2L, 3L); + given(roomParticipantService.getParticipants(roomId)).willReturn(expectedUsers); - // when & then - assertThatNoException().isThrownBy(() -> - sessionManager.updateLastActivity(TEST_USER_ID) - ); + // when + Set result = sessionManager.getOnlineUsersInRoom(roomId); - // 세션이 없으면 업데이트하지 않음 - verify(valueOperations, never()).set(anyString(), any(), any(Duration.class)); + // then + assertThat(result).containsExactlyInAnyOrderElementsOf(expectedUsers); + verify(roomParticipantService).getParticipants(roomId); } @Test - @DisplayName("방 입장 - 성공") - void t11() { + @DisplayName("사용자의 현재 방 조회") + void getUserCurrentRoomId() { // given - when(redisTemplate.opsForValue()).thenReturn(valueOperations); - when(redisTemplate.opsForSet()).thenReturn(setOperations); - - WebSocketSessionInfo sessionInfo = WebSocketSessionInfo - .createNewSession(TEST_USER_ID, TEST_SESSION_ID); - - when(valueOperations.get("ws:user:123")).thenReturn(sessionInfo); + given(roomParticipantService.getCurrentRoomId(userId)).willReturn(roomId); // when - assertThatNoException().isThrownBy(() -> - sessionManager.joinRoom(TEST_USER_ID, TEST_ROOM_ID) - ); + Long result = sessionManager.getUserCurrentRoomId(userId); // then - verify(setOperations).add("ws:room:456:users", TEST_USER_ID); - verify(redisTemplate).expire("ws:room:456:users", Duration.ofMinutes(6)); - verify(valueOperations).set(eq("ws:user:123"), any(WebSocketSessionInfo.class), eq(Duration.ofMinutes(6))); + assertThat(result).isEqualTo(roomId); + verify(roomParticipantService).getCurrentRoomId(userId); } @Test - @DisplayName("방 입장 - 기존 방에서 자동 퇴장 후 새 방 입장") - void t12() { + @DisplayName("사용자가 특정 방에 참여 중인지 확인") + void isUserInRoom() { // given - when(redisTemplate.opsForValue()).thenReturn(valueOperations); - when(redisTemplate.opsForSet()).thenReturn(setOperations); - Long previousRoomId = 999L; - - WebSocketSessionInfo sessionInfo = WebSocketSessionInfo - .createNewSession(TEST_USER_ID, TEST_SESSION_ID) - .withRoomId(previousRoomId); - - when(valueOperations.get("ws:user:123")).thenReturn(sessionInfo); + given(roomParticipantService.isUserInRoom(userId, roomId)).willReturn(true); // when - assertThatNoException().isThrownBy(() -> - sessionManager.joinRoom(TEST_USER_ID, TEST_ROOM_ID) - ); + boolean result = sessionManager.isUserInRoom(userId, roomId); // then - // 이전 방에서 퇴장 - verify(setOperations).remove("ws:room:999:users", TEST_USER_ID); + assertThat(result).isTrue(); + verify(roomParticipantService).isUserInRoom(userId, roomId); + } + + @Test + @DisplayName("전체 플로우: 연결 → 방 입장 → Heartbeat → 방 퇴장 → 연결 종료") + void fullLifecycleFlow() { + // given + given(userSessionService.getUserIdBySessionId(sessionId)).willReturn(userId); - // 새 방에 입장 - verify(setOperations).add("ws:room:456:users", TEST_USER_ID); + // when & then + // 1. 연결 + sessionManager.addSession(userId, sessionId); + verify(userSessionService).registerSession(userId, sessionId); + + // 2. 방 입장 + sessionManager.joinRoom(userId, roomId); + verify(roomParticipantService).enterRoom(userId, roomId); + + // 3. Heartbeat + sessionManager.updateLastActivity(userId); + verify(userSessionService).processHeartbeat(userId); + + // 4. 방 퇴장 + sessionManager.leaveRoom(userId, roomId); + verify(roomParticipantService).exitRoom(userId, roomId); + + // 5. 연결 종료 + sessionManager.removeSession(sessionId); + verify(roomParticipantService).exitAllRooms(userId); + verify(userSessionService).terminateSession(sessionId); } @Test - @DisplayName("방 퇴장 - 성공") - void t13() { + @DisplayName("전체 플로우: 연결 → 방 A 입장 → 방 B 이동 → 연결 종료") + void fullLifecycleFlow_RoomTransition() { // given - when(redisTemplate.opsForValue()).thenReturn(valueOperations); - when(redisTemplate.opsForSet()).thenReturn(setOperations); + Long roomA = 100L; + Long roomB = 200L; + given(userSessionService.getUserIdBySessionId(sessionId)).willReturn(userId); - WebSocketSessionInfo sessionInfo = WebSocketSessionInfo - .createNewSession(TEST_USER_ID, TEST_SESSION_ID) - .withRoomId(TEST_ROOM_ID); + // when & then + // 1. 연결 + sessionManager.addSession(userId, sessionId); + verify(userSessionService).registerSession(userId, sessionId); + + // 2. 방 A 입장 + sessionManager.joinRoom(userId, roomA); + verify(roomParticipantService).enterRoom(userId, roomA); + + // 3. 방 B로 이동 (자동으로 방 A 퇴장) + sessionManager.joinRoom(userId, roomB); + verify(roomParticipantService).enterRoom(userId, roomB); + + // 4. 연결 종료 (모든 방에서 퇴장) + sessionManager.removeSession(sessionId); + verify(roomParticipantService).exitAllRooms(userId); + verify(userSessionService).terminateSession(sessionId); + } - when(valueOperations.get("ws:user:123")).thenReturn(sessionInfo); + @Test + @DisplayName("여러 사용자의 동시 세션 관리") + void multipleUsersSessions() { + // given + Long userId1 = 1L; + Long userId2 = 2L; + Long userId3 = 3L; + String sessionId1 = "session-1"; + String sessionId2 = "session-2"; + String sessionId3 = "session-3"; // when - assertThatNoException().isThrownBy(() -> - sessionManager.leaveRoom(TEST_USER_ID, TEST_ROOM_ID) - ); + sessionManager.addSession(userId1, sessionId1); + sessionManager.addSession(userId2, sessionId2); + sessionManager.addSession(userId3, sessionId3); // then - verify(setOperations).remove("ws:room:456:users", TEST_USER_ID); - verify(valueOperations).set(eq("ws:user:123"), any(WebSocketSessionInfo.class), eq(Duration.ofMinutes(6))); + verify(userSessionService).registerSession(userId1, sessionId1); + verify(userSessionService).registerSession(userId2, sessionId2); + verify(userSessionService).registerSession(userId3, sessionId3); } @Test - @DisplayName("방 온라인 사용자 수 조회 - 성공") - void t14() { + @DisplayName("여러 사용자가 같은 방에 입장") + void multipleUsersInSameRoom() { // given - when(redisTemplate.opsForSet()).thenReturn(setOperations); - when(setOperations.size("ws:room:456:users")).thenReturn(5L); + Long userId1 = 1L; + Long userId2 = 2L; + Long userId3 = 3L; // when - long result = sessionManager.getRoomOnlineUserCount(TEST_ROOM_ID); + sessionManager.joinRoom(userId1, roomId); + sessionManager.joinRoom(userId2, roomId); + sessionManager.joinRoom(userId3, roomId); // then - assertThat(result).isEqualTo(5L); - verify(setOperations).size("ws:room:456:users"); + verify(roomParticipantService).enterRoom(userId1, roomId); + verify(roomParticipantService).enterRoom(userId2, roomId); + verify(roomParticipantService).enterRoom(userId3, roomId); } @Test - @DisplayName("방 온라인 사용자 수 조회 - 사용자가 없을 때") - void t15() { + @DisplayName("중복 연결 시도 (기존 세션 종료 후 새 세션 등록)") + void duplicateConnection() { // given - when(redisTemplate.opsForSet()).thenReturn(setOperations); - when(setOperations.size("ws:room:456:users")).thenReturn(null); + String newSessionId = "new-session-456"; // when - long result = sessionManager.getRoomOnlineUserCount(TEST_ROOM_ID); + sessionManager.addSession(userId, sessionId); + sessionManager.addSession(userId, newSessionId); // then - assertThat(result).isEqualTo(0L); + verify(userSessionService).registerSession(userId, sessionId); + verify(userSessionService).registerSession(userId, newSessionId); + // UserSessionService 내부에서 기존 세션 종료 처리 } @Test - @DisplayName("방 온라인 사용자 목록 조회 - 성공") - void t16() { + @DisplayName("비정상 종료 시나리오: 명시적 퇴장 없이 연결 종료") + void abnormalDisconnection() { // given - when(redisTemplate.opsForSet()).thenReturn(setOperations); - Set expectedUserIds = Set.of(123L, 456L, 789L); - when(setOperations.members("ws:room:456:users")).thenReturn(expectedUserIds); + given(userSessionService.getUserIdBySessionId(sessionId)).willReturn(userId); // when - Set result = sessionManager.getOnlineUsersInRoom(TEST_ROOM_ID); + sessionManager.addSession(userId, sessionId); + sessionManager.joinRoom(userId, roomId); + // 명시적 leaveRoom 없이 바로 연결 종료 + sessionManager.removeSession(sessionId); // then - assertThat(result).containsExactlyInAnyOrder(123L, 456L, 789L); - verify(setOperations).members("ws:room:456:users"); + verify(roomParticipantService).enterRoom(userId, roomId); + verify(roomParticipantService).exitAllRooms(userId); // 모든 방에서 자동 퇴장 + verify(userSessionService).terminateSession(sessionId); } @Test - @DisplayName("방 온라인 사용자 목록 조회 - 빈 방") - void t17() { + @DisplayName("방 입장 전 상태 조회") + void queryBeforeJoinRoom() { // given - when(redisTemplate.opsForSet()).thenReturn(setOperations); - when(setOperations.members("ws:room:456:users")).thenReturn(null); + given(roomParticipantService.getCurrentRoomId(userId)).willReturn(null); + given(roomParticipantService.isUserInRoom(userId, roomId)).willReturn(false); // when - Set result = sessionManager.getOnlineUsersInRoom(TEST_ROOM_ID); + Long currentRoomId = sessionManager.getUserCurrentRoomId(userId); + boolean isInRoom = sessionManager.isUserInRoom(userId, roomId); // then - assertThat(result).isEmpty(); + assertThat(currentRoomId).isNull(); + assertThat(isInRoom).isFalse(); + verify(roomParticipantService).getCurrentRoomId(userId); + verify(roomParticipantService).isUserInRoom(userId, roomId); } @Test - @DisplayName("전체 온라인 사용자 수 조회 - 성공") - void t18() { + @DisplayName("방 입장 후 상태 조회") + void queryAfterJoinRoom() { // given - Set userKeys = Set.of("ws:user:123", "ws:user:456", "ws:user:789"); - when(redisTemplate.keys("ws:user:*")).thenReturn(userKeys); + given(roomParticipantService.getCurrentRoomId(userId)).willReturn(roomId); + given(roomParticipantService.isUserInRoom(userId, roomId)).willReturn(true); // when - long result = sessionManager.getTotalOnlineUserCount(); + sessionManager.joinRoom(userId, roomId); + Long currentRoomId = sessionManager.getUserCurrentRoomId(userId); + boolean isInRoom = sessionManager.isUserInRoom(userId, roomId); // then - assertThat(result).isEqualTo(3L); + assertThat(currentRoomId).isEqualTo(roomId); + assertThat(isInRoom).isTrue(); + verify(roomParticipantService).enterRoom(userId, roomId); + verify(roomParticipantService).getCurrentRoomId(userId); + verify(roomParticipantService).isUserInRoom(userId, roomId); } @Test - @DisplayName("전체 온라인 사용자 수 조회 - Redis 오류 시 0 반환") - void t19() { - // given - when(redisTemplate.keys("ws:user:*")).thenThrow(new RuntimeException("Redis error")); - + @DisplayName("Heartbeat 여러 번 호출") + void multipleHeartbeats() { // when - long result = sessionManager.getTotalOnlineUserCount(); + sessionManager.updateLastActivity(userId); + sessionManager.updateLastActivity(userId); + sessionManager.updateLastActivity(userId); // then - assertThat(result).isEqualTo(0L); // 예외 대신 0 반환 + verify(userSessionService, times(3)).processHeartbeat(userId); } @Test - @DisplayName("사용자 현재 방 ID 조회 - 성공") - void t20() { + @DisplayName("빈 방의 사용자 목록 조회") + void getOnlineUsersInRoom_EmptyRoom() { // given - when(redisTemplate.opsForValue()).thenReturn(valueOperations); - - WebSocketSessionInfo sessionInfo = WebSocketSessionInfo - .createNewSession(TEST_USER_ID, TEST_SESSION_ID) - .withRoomId(TEST_ROOM_ID); - - when(valueOperations.get("ws:user:123")).thenReturn(sessionInfo); + given(roomParticipantService.getParticipants(roomId)).willReturn(Set.of()); // when - Long result = sessionManager.getUserCurrentRoomId(TEST_USER_ID); + Set result = sessionManager.getOnlineUsersInRoom(roomId); // then - assertThat(result).isEqualTo(TEST_ROOM_ID); + assertThat(result).isEmpty(); + verify(roomParticipantService).getParticipants(roomId); } @Test - @DisplayName("사용자 현재 방 ID 조회 - 방에 입장하지 않음") - void t21() { + @DisplayName("온라인 사용자가 없을 때 전체 수 조회") + void getTotalOnlineUserCount_NoUsers() { // given - when(redisTemplate.opsForValue()).thenReturn(valueOperations); - - // 방 정보 없는 세션 - WebSocketSessionInfo sessionInfo = WebSocketSessionInfo - .createNewSession(TEST_USER_ID, TEST_SESSION_ID); // currentRoomId는 null - - when(valueOperations.get("ws:user:123")).thenReturn(sessionInfo); + given(userSessionService.getTotalOnlineUserCount()).willReturn(0L); // when - Long result = sessionManager.getUserCurrentRoomId(TEST_USER_ID); + long result = sessionManager.getTotalOnlineUserCount(); // then - assertThat(result).isNull(); + assertThat(result).isZero(); + verify(userSessionService).getTotalOnlineUserCount(); } @Test - @DisplayName("사용자 현재 방 ID 조회 - 세션이 없음") - void t22() { + @DisplayName("세션 제거 시 모든 정리 작업이 순서대로 실행됨") + void removeSession_VerifyExecutionOrder() { // given - when(redisTemplate.opsForValue()).thenReturn(valueOperations); - when(valueOperations.get("ws:user:123")).thenReturn(null); + given(userSessionService.getUserIdBySessionId(sessionId)).willReturn(userId); // when - Long result = sessionManager.getUserCurrentRoomId(TEST_USER_ID); + sessionManager.removeSession(sessionId); // then - assertThat(result).isNull(); + // InOrder를 사용하여 실행 순서 검증 + var inOrder = inOrder(userSessionService, roomParticipantService); + inOrder.verify(userSessionService).getUserIdBySessionId(sessionId); + inOrder.verify(roomParticipantService).exitAllRooms(userId); + inOrder.verify(userSessionService).terminateSession(sessionId); } @Test - @DisplayName("세션 제거 - 성공") - void t23() { + @DisplayName("방 입장 실패 시 예외 전파") + void joinRoom_ExceptionPropagation() { // given - when(redisTemplate.opsForValue()).thenReturn(valueOperations); - when(redisTemplate.opsForSet()).thenReturn(setOperations); - when(valueOperations.get("ws:session:session-123")).thenReturn(TEST_USER_ID); + willThrow(new CustomException(ErrorCode.WS_SESSION_NOT_FOUND)) + .given(roomParticipantService).enterRoom(userId, roomId); - WebSocketSessionInfo sessionInfo = WebSocketSessionInfo - .createNewSession(TEST_USER_ID, TEST_SESSION_ID) - .withRoomId(TEST_ROOM_ID); - when(valueOperations.get("ws:user:123")).thenReturn(sessionInfo); + // when & then + assertThatThrownBy(() -> sessionManager.joinRoom(userId, roomId)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.WS_SESSION_NOT_FOUND); - // when - assertThatNoException().isThrownBy(() -> - sessionManager.removeSession(TEST_SESSION_ID) - ); + verify(roomParticipantService).enterRoom(userId, roomId); + } - // then - verify(setOperations).remove("ws:room:456:users", TEST_USER_ID); // 방에서 퇴장 - verify(redisTemplate, times(2)).delete(anyString()); // 세션 데이터 삭제 + @Test + @DisplayName("통합 시나리오: 사용자 A와 B가 같은 방에서 만남") + void integrationScenario_TwoUsersInSameRoom() { + // given + Long userA = 1L; + Long userB = 2L; + String sessionA = "session-A"; + String sessionB = "session-B"; + + given(roomParticipantService.getParticipants(roomId)) + .willReturn(Set.of(userA)) + .willReturn(Set.of(userA, userB)); + + // when & then + // 1. 사용자 A 연결 및 방 입장 + sessionManager.addSession(userA, sessionA); + sessionManager.joinRoom(userA, roomId); + + Set usersAfterA = sessionManager.getOnlineUsersInRoom(roomId); + assertThat(usersAfterA).containsExactly(userA); + + // 2. 사용자 B 연결 및 같은 방 입장 + sessionManager.addSession(userB, sessionB); + sessionManager.joinRoom(userB, roomId); + + Set usersAfterB = sessionManager.getOnlineUsersInRoom(roomId); + assertThat(usersAfterB).containsExactlyInAnyOrder(userA, userB); + + // 3. 검증 + verify(userSessionService).registerSession(userA, sessionA); + verify(userSessionService).registerSession(userB, sessionB); + verify(roomParticipantService).enterRoom(userA, roomId); + verify(roomParticipantService).enterRoom(userB, roomId); + verify(roomParticipantService, times(2)).getParticipants(roomId); } @Test - @DisplayName("세션 제거 - 존재하지 않는 세션") - void t24() { + @DisplayName("통합 시나리오: 네트워크 불안정으로 재연결") + void integrationScenario_Reconnection() { // given - when(redisTemplate.opsForValue()).thenReturn(valueOperations); - when(valueOperations.get("ws:session:session-123")).thenReturn(null); + String newSessionId = "new-session-789"; + given(userSessionService.getUserIdBySessionId(sessionId)).willReturn(userId); // when & then - assertThatNoException().isThrownBy(() -> - sessionManager.removeSession(TEST_SESSION_ID) - ); + // 1. 초기 연결 및 방 입장 + sessionManager.addSession(userId, sessionId); + sessionManager.joinRoom(userId, roomId); + + // 2. 갑작스런 연결 끊김 + sessionManager.removeSession(sessionId); + verify(roomParticipantService).exitAllRooms(userId); + + // 3. 재연결 (새 세션 ID로) + sessionManager.addSession(userId, newSessionId); + verify(userSessionService).registerSession(userId, newSessionId); - // 아무것도 삭제하지 않음 - verify(redisTemplate, never()).delete(anyString()); + // 4. 다시 방 입장 + sessionManager.joinRoom(userId, roomId); + verify(roomParticipantService, times(2)).enterRoom(userId, roomId); } } \ No newline at end of file diff --git a/src/test/java/com/back/global/websocket/store/RedisSessionStoreTest.java b/src/test/java/com/back/global/websocket/store/RedisSessionStoreTest.java new file mode 100644 index 00000000..1d3406fc --- /dev/null +++ b/src/test/java/com/back/global/websocket/store/RedisSessionStoreTest.java @@ -0,0 +1,424 @@ +package com.back.global.websocket.store; + +import com.back.global.websocket.config.WebSocketConstants; +import com.back.global.websocket.dto.WebSocketSessionInfo; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import java.util.Set; +import static org.assertj.core.api.Assertions.*; + +@SpringBootTest +@Testcontainers +@DisplayName("RedisSessionStore 통합 테스트") +class RedisSessionStoreTest { + + @Container + static GenericContainer redis = new GenericContainer<>(DockerImageName.parse("redis:7-alpine")) + .withExposedPorts(6379); + + @DynamicPropertySource + static void redisProperties(DynamicPropertyRegistry registry) { + registry.add("spring.data.redis.host", redis::getHost); + registry.add("spring.data.redis.port", redis::getFirstMappedPort); + } + + @Autowired + private RedisSessionStore redisSessionStore; + + @Autowired + private RedisTemplate redisTemplate; + + @BeforeEach + void setUp() { + // Redis 초기화 + redisTemplate.getConnectionFactory().getConnection().flushAll(); + } + + @AfterEach + void tearDown() { + // 테스트 후 정리 + redisTemplate.getConnectionFactory().getConnection().flushAll(); + } + + @Test + @DisplayName("사용자 세션 저장 및 조회") + void t1() { + // given + Long userId = 1L; + String sessionId = "test-session-123"; + WebSocketSessionInfo sessionInfo = WebSocketSessionInfo.createNewSession(userId, sessionId); + + // when + redisSessionStore.saveUserSession(userId, sessionInfo); + WebSocketSessionInfo retrieved = redisSessionStore.getUserSession(userId); + + // then + assertThat(retrieved).isNotNull(); + assertThat(retrieved.userId()).isEqualTo(userId); + assertThat(retrieved.sessionId()).isEqualTo(sessionId); + assertThat(retrieved.currentRoomId()).isNull(); + assertThat(retrieved.connectedAt()).isNotNull(); + assertThat(retrieved.lastActiveAt()).isNotNull(); + } + + @Test + @DisplayName("존재하지 않는 사용자 세션 조회 시 null 반환") + void t2() { + // given + Long userId = 999L; + + // when + WebSocketSessionInfo result = redisSessionStore.getUserSession(userId); + + // then + assertThat(result).isNull(); + } + + @Test + @DisplayName("세션-사용자 매핑 저장 및 조회") + void t3() { + // given + String sessionId = "test-session-456"; + Long userId = 2L; + + // when + redisSessionStore.saveSessionUserMapping(sessionId, userId); + Long retrievedUserId = redisSessionStore.getUserIdBySession(sessionId); + + // then + assertThat(retrievedUserId).isEqualTo(userId); + } + + @Test + @DisplayName("존재하지 않는 세션으로 사용자 조회 시 null 반환") + void t4() { + // given + String sessionId = "non-existent-session"; + + // when + Long result = redisSessionStore.getUserIdBySession(sessionId); + + // then + assertThat(result).isNull(); + } + + @Test + @DisplayName("사용자 세션 삭제") + void t5() { + // given + Long userId = 3L; + String sessionId = "test-session-789"; + WebSocketSessionInfo sessionInfo = WebSocketSessionInfo.createNewSession(userId, sessionId); + redisSessionStore.saveUserSession(userId, sessionInfo); + + // when + redisSessionStore.deleteUserSession(userId); + WebSocketSessionInfo result = redisSessionStore.getUserSession(userId); + + // then + assertThat(result).isNull(); + } + + @Test + @DisplayName("세션-사용자 매핑 삭제") + void t6() { + // given + String sessionId = "test-session-delete"; + Long userId = 4L; + redisSessionStore.saveSessionUserMapping(sessionId, userId); + + // when + redisSessionStore.deleteSessionUserMapping(sessionId); + Long result = redisSessionStore.getUserIdBySession(sessionId); + + // then + assertThat(result).isNull(); + } + + @Test + @DisplayName("사용자 세션 존재 여부 확인") + void t7() { + // given + Long userId = 5L; + String sessionId = "test-session-exists"; + WebSocketSessionInfo sessionInfo = WebSocketSessionInfo.createNewSession(userId, sessionId); + + // when & then + assertThat(redisSessionStore.existsUserSession(userId)).isFalse(); + + redisSessionStore.saveUserSession(userId, sessionInfo); + assertThat(redisSessionStore.existsUserSession(userId)).isTrue(); + + redisSessionStore.deleteUserSession(userId); + assertThat(redisSessionStore.existsUserSession(userId)).isFalse(); + } + + @Test + @DisplayName("방에 사용자 추가") + void t8() { + // given + Long roomId = 100L; + Long userId = 6L; + + // when + redisSessionStore.addUserToRoom(roomId, userId); + Set roomUsers = redisSessionStore.getRoomUsers(roomId); + + // then + assertThat(roomUsers).contains(userId); + assertThat(roomUsers).hasSize(1); + } + + @Test + @DisplayName("방에 여러 사용자 추가") + void t9() { + // given + Long roomId = 101L; + Long userId1 = 7L; + Long userId2 = 8L; + Long userId3 = 9L; + + // when + redisSessionStore.addUserToRoom(roomId, userId1); + redisSessionStore.addUserToRoom(roomId, userId2); + redisSessionStore.addUserToRoom(roomId, userId3); + + Set roomUsers = redisSessionStore.getRoomUsers(roomId); + long userCount = redisSessionStore.getRoomUserCount(roomId); + + // then + assertThat(roomUsers).containsExactlyInAnyOrder(userId1, userId2, userId3); + assertThat(userCount).isEqualTo(3); + } + + @Test + @DisplayName("방에서 사용자 제거") + void t10() { + // given + Long roomId = 102L; + Long userId1 = 10L; + Long userId2 = 11L; + redisSessionStore.addUserToRoom(roomId, userId1); + redisSessionStore.addUserToRoom(roomId, userId2); + + // when + redisSessionStore.removeUserFromRoom(roomId, userId1); + Set roomUsers = redisSessionStore.getRoomUsers(roomId); + + // then + assertThat(roomUsers).containsExactly(userId2); + assertThat(roomUsers).doesNotContain(userId1); + } + + @Test + @DisplayName("존재하지 않는 방의 사용자 목록 조회 시 빈 Set 반환") + void t11() { + // given + Long roomId = 999L; + + // when + Set roomUsers = redisSessionStore.getRoomUsers(roomId); + + // then + assertThat(roomUsers).isEmpty(); + } + + @Test + @DisplayName("방의 사용자 수 조회") + void t12() { + // given + Long roomId = 103L; + redisSessionStore.addUserToRoom(roomId, 12L); + redisSessionStore.addUserToRoom(roomId, 13L); + redisSessionStore.addUserToRoom(roomId, 14L); + + // when + long count = redisSessionStore.getRoomUserCount(roomId); + + // then + assertThat(count).isEqualTo(3); + } + + @Test + @DisplayName("존재하지 않는 방의 사용자 수는 0") + void t13() { + // given + Long roomId = 999L; + + // when + long count = redisSessionStore.getRoomUserCount(roomId); + + // then + assertThat(count).isZero(); + } + + @Test + @DisplayName("전체 온라인 사용자 수 조회") + void t14() { + // given + Long userId1 = 15L; + Long userId2 = 16L; + Long userId3 = 17L; + + WebSocketSessionInfo session1 = WebSocketSessionInfo.createNewSession(userId1, "session-1"); + WebSocketSessionInfo session2 = WebSocketSessionInfo.createNewSession(userId2, "session-2"); + WebSocketSessionInfo session3 = WebSocketSessionInfo.createNewSession(userId3, "session-3"); + + // when + redisSessionStore.saveUserSession(userId1, session1); + redisSessionStore.saveUserSession(userId2, session2); + redisSessionStore.saveUserSession(userId3, session3); + + long totalCount = redisSessionStore.getTotalOnlineUserCount(); + + // then + assertThat(totalCount).isEqualTo(3); + } + + @Test + @DisplayName("세션 정보에 방 ID 추가 후 저장 및 조회") + void t15() { + // given + Long userId = 18L; + Long roomId = 200L; + String sessionId = "session-with-room"; + WebSocketSessionInfo sessionInfo = WebSocketSessionInfo.createNewSession(userId, sessionId); + WebSocketSessionInfo withRoom = sessionInfo.withRoomId(roomId); + + // when + redisSessionStore.saveUserSession(userId, withRoom); + WebSocketSessionInfo retrieved = redisSessionStore.getUserSession(userId); + + // then + assertThat(retrieved).isNotNull(); + assertThat(retrieved.currentRoomId()).isEqualTo(roomId); + assertThat(retrieved.userId()).isEqualTo(userId); + assertThat(retrieved.sessionId()).isEqualTo(sessionId); + } + + @Test + @DisplayName("세션 정보에서 방 ID 제거 후 저장 및 조회") + void t16() { + // given + Long userId = 19L; + Long roomId = 201L; + String sessionId = "session-remove-room"; + WebSocketSessionInfo sessionInfo = WebSocketSessionInfo.createNewSession(userId, sessionId); + WebSocketSessionInfo withRoom = sessionInfo.withRoomId(roomId); + redisSessionStore.saveUserSession(userId, withRoom); + + // when + WebSocketSessionInfo withoutRoom = withRoom.withoutRoom(); + redisSessionStore.saveUserSession(userId, withoutRoom); + WebSocketSessionInfo retrieved = redisSessionStore.getUserSession(userId); + + // then + assertThat(retrieved).isNotNull(); + assertThat(retrieved.currentRoomId()).isNull(); + } + + @Test + @DisplayName("활동 시간 업데이트 후 저장 및 조회") + void t17() throws InterruptedException { + // given + Long userId = 20L; + String sessionId = "session-activity-update"; + WebSocketSessionInfo sessionInfo = WebSocketSessionInfo.createNewSession(userId, sessionId); + redisSessionStore.saveUserSession(userId, sessionInfo); + + // when + Thread.sleep(100); // 시간 차이를 만들기 위해 대기 + WebSocketSessionInfo updatedSession = sessionInfo.withUpdatedActivity(); + redisSessionStore.saveUserSession(userId, updatedSession); + WebSocketSessionInfo retrieved = redisSessionStore.getUserSession(userId); + + // then + assertThat(retrieved).isNotNull(); + assertThat(retrieved.lastActiveAt()).isAfter(sessionInfo.lastActiveAt()); + } + + @Test + @DisplayName("중복 사용자를 같은 방에 추가해도 한 번만 저장됨") + void t18() { + // given + Long roomId = 104L; + Long userId = 21L; + + // when + redisSessionStore.addUserToRoom(roomId, userId); + redisSessionStore.addUserToRoom(roomId, userId); + redisSessionStore.addUserToRoom(roomId, userId); + + Set roomUsers = redisSessionStore.getRoomUsers(roomId); + + // then + assertThat(roomUsers).containsExactly(userId); + assertThat(roomUsers).hasSize(1); + } + + @Test + @DisplayName("세션 TTL이 설정되는지 확인") + void t19() { + // given + Long userId = 22L; + String sessionId = "session-ttl-check"; + WebSocketSessionInfo sessionInfo = WebSocketSessionInfo.createNewSession(userId, sessionId); + + // when + redisSessionStore.saveUserSession(userId, sessionInfo); + String userKey = WebSocketConstants.buildUserSessionKey(userId); + Long ttl = redisTemplate.getExpire(userKey); + + // then + assertThat(ttl).isNotNull(); + assertThat(ttl).isGreaterThan(0); + assertThat(ttl).isLessThanOrEqualTo(WebSocketConstants.SESSION_TTL.getSeconds()); + } + + @Test + @DisplayName("방 사용자 키에 TTL이 설정되는지 확인") + void t20() { + // given + Long roomId = 105L; + Long userId = 23L; + + // when + redisSessionStore.addUserToRoom(roomId, userId); + String roomUsersKey = WebSocketConstants.buildRoomUsersKey(roomId); + Long ttl = redisTemplate.getExpire(roomUsersKey); + + // then + assertThat(ttl).isNotNull(); + assertThat(ttl).isGreaterThan(0); + assertThat(ttl).isLessThanOrEqualTo(WebSocketConstants.SESSION_TTL.getSeconds()); + } + + @Test + @DisplayName("Integer 타입 userId도 Long으로 변환하여 조회 가능") + void t21() { + // given + String sessionId = "session-integer-test"; + + // Redis에 Integer로 저장 (실제로는 RedisTemplate이 변환할 수 있음) + String sessionKey = WebSocketConstants.buildSessionUserKey(sessionId); + redisTemplate.opsForValue().set(sessionKey, 24, WebSocketConstants.SESSION_TTL); + + // when + Long retrievedUserId = redisSessionStore.getUserIdBySession(sessionId); + + // then + assertThat(retrievedUserId).isEqualTo(24L); + } +} \ No newline at end of file diff --git a/src/test/java/com/back/global/websocket/webrtc/service/WebRTCSignalValidatorTest.java b/src/test/java/com/back/global/websocket/webrtc/service/WebRTCSignalValidatorTest.java index fef7bda8..f14b02e8 100644 --- a/src/test/java/com/back/global/websocket/webrtc/service/WebRTCSignalValidatorTest.java +++ b/src/test/java/com/back/global/websocket/webrtc/service/WebRTCSignalValidatorTest.java @@ -51,19 +51,12 @@ void setUp() { User fromUser = mock(User.class); User targetUser = mock(User.class); - // 온라인 멤버들 + // 온라인/오프라인 구분은 Redis로 이관 예정 + // 현재는 멤버 존재 여부만 체크 onlineFromMember = RoomMember.createMember(mockRoom, fromUser); - onlineFromMember.updateOnlineStatus(true); - onlineTargetMember = RoomMember.createMember(mockRoom, targetUser); - onlineTargetMember.updateOnlineStatus(true); - - // 오프라인 멤버들 offlineFromMember = RoomMember.createMember(mockRoom, fromUser); - offlineFromMember.updateOnlineStatus(false); - offlineTargetMember = RoomMember.createMember(mockRoom, targetUser); - offlineTargetMember.updateOnlineStatus(false); } @Nested @@ -114,24 +107,9 @@ void t3() { verify(roomMemberRepository).findByRoomIdAndUserId(roomId, fromUserId); } - @Test - @DisplayName("실패 - 발신자가 오프라인") - void t4() { - // given - given(roomMemberRepository.findByRoomIdAndUserId(roomId, fromUserId)) - .willReturn(Optional.of(offlineFromMember)); - - // when & then - assertThatThrownBy(() -> validator.validateSignal(roomId, fromUserId, targetUserId)) - .isInstanceOf(CustomException.class) - .hasFieldOrPropertyWithValue("errorCode", ErrorCode.NOT_ROOM_MEMBER); - - verify(roomMemberRepository).findByRoomIdAndUserId(roomId, fromUserId); - } - @Test @DisplayName("실패 - 수신자가 방에 없음") - void t5() { + void t4() { // given given(roomMemberRepository.findByRoomIdAndUserId(roomId, fromUserId)) .willReturn(Optional.of(onlineFromMember)); @@ -146,39 +124,6 @@ void t5() { verify(roomMemberRepository).findByRoomIdAndUserId(roomId, fromUserId); verify(roomMemberRepository).findByRoomIdAndUserId(roomId, targetUserId); } - - @Test - @DisplayName("실패 - 수신자가 오프라인") - void t6() { - // given - given(roomMemberRepository.findByRoomIdAndUserId(roomId, fromUserId)) - .willReturn(Optional.of(onlineFromMember)); - given(roomMemberRepository.findByRoomIdAndUserId(roomId, targetUserId)) - .willReturn(Optional.of(offlineTargetMember)); - - // when & then - assertThatThrownBy(() -> validator.validateSignal(roomId, fromUserId, targetUserId)) - .isInstanceOf(CustomException.class) - .hasFieldOrPropertyWithValue("errorCode", ErrorCode.NOT_ROOM_MEMBER); - - verify(roomMemberRepository).findByRoomIdAndUserId(roomId, fromUserId); - verify(roomMemberRepository).findByRoomIdAndUserId(roomId, targetUserId); - } - - @Test - @DisplayName("실패 - 발신자와 수신자 모두 오프라인") - void t7() { - // given - given(roomMemberRepository.findByRoomIdAndUserId(roomId, fromUserId)) - .willReturn(Optional.of(offlineFromMember)); - - // when & then - assertThatThrownBy(() -> validator.validateSignal(roomId, fromUserId, targetUserId)) - .isInstanceOf(CustomException.class) - .hasFieldOrPropertyWithValue("errorCode", ErrorCode.NOT_ROOM_MEMBER); - - verify(roomMemberRepository).findByRoomIdAndUserId(roomId, fromUserId); - } } @Nested @@ -186,8 +131,8 @@ void t7() { class ValidateMediaStateChangeTest { @Test - @DisplayName("정상 - 온라인 멤버") - void t8() { + @DisplayName("정상 - 멤버 존재") + void t5() { // given given(roomMemberRepository.findByRoomIdAndUserId(roomId, fromUserId)) .willReturn(Optional.of(onlineFromMember)); @@ -201,7 +146,7 @@ void t8() { @Test @DisplayName("실패 - 방에 없는 사용자") - void t9() { + void t6() { // given given(roomMemberRepository.findByRoomIdAndUserId(roomId, fromUserId)) .willReturn(Optional.empty()); @@ -215,30 +160,14 @@ void t9() { } @Test - @DisplayName("실패 - 오프라인 사용자") - void t10() { - // given - given(roomMemberRepository.findByRoomIdAndUserId(roomId, fromUserId)) - .willReturn(Optional.of(offlineFromMember)); - - // when & then - assertThatThrownBy(() -> validator.validateMediaStateChange(roomId, fromUserId)) - .isInstanceOf(CustomException.class) - .hasFieldOrPropertyWithValue("errorCode", ErrorCode.NOT_ROOM_MEMBER); - - verify(roomMemberRepository).findByRoomIdAndUserId(roomId, fromUserId); - } - - @Test - @DisplayName("정상 - 다른 방의 온라인 멤버") - void t11() { + @DisplayName("정상 - 다른 방의 멤버") + void t7() { // given Long differentRoomId = 999L; Room differentRoom = mock(Room.class); User user = mock(User.class); RoomMember memberInDifferentRoom = RoomMember.createMember(differentRoom, user); - memberInDifferentRoom.updateOnlineStatus(true); given(roomMemberRepository.findByRoomIdAndUserId(differentRoomId, fromUserId)) .willReturn(Optional.of(memberInDifferentRoom));