diff --git a/src/main/java/com/back/domain/studyroom/controller/RoomAnnouncementController.java b/src/main/java/com/back/domain/studyroom/controller/RoomAnnouncementController.java new file mode 100644 index 00000000..30bf8178 --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/controller/RoomAnnouncementController.java @@ -0,0 +1,171 @@ +package com.back.domain.studyroom.controller; + +import com.back.domain.studyroom.dto.CreateAnnouncementRequest; +import com.back.domain.studyroom.dto.RoomAnnouncementResponse; +import com.back.domain.studyroom.dto.UpdateAnnouncementRequest; +import com.back.domain.studyroom.service.RoomAnnouncementService; +import com.back.global.common.dto.RsData; +import com.back.global.security.user.CurrentUser; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 방 공지사항 API 컨트롤러 + */ +@RestController +@RequestMapping("/api/rooms/{roomId}/announcements") +@RequiredArgsConstructor +@Tag(name = "Room Announcement API", description = "방 공지사항 관련 API") +@SecurityRequirement(name = "Bearer Authentication") +public class RoomAnnouncementController { + + private final RoomAnnouncementService announcementService; + private final CurrentUser currentUser; + + @PostMapping + @Operation( + summary = "공지사항 생성", + description = "새로운 공지사항을 생성합니다. 방장만 생성 가능합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "공지사항 생성 성공"), + @ApiResponse(responseCode = "403", description = "방장 권한 없음"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 방"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + public ResponseEntity> createAnnouncement( + @Parameter(description = "방 ID", required = true) @PathVariable Long roomId, + @Valid @RequestBody CreateAnnouncementRequest request) { + + Long userId = currentUser.getUserId(); + RoomAnnouncementResponse response = announcementService.createAnnouncement( + roomId, request.getTitle(), request.getContent(), userId); + + return ResponseEntity + .status(HttpStatus.CREATED) + .body(RsData.success("공지사항 생성 완료", response)); + } + + @GetMapping + @Operation( + summary = "공지사항 목록 조회", + description = "방의 모든 공지사항을 조회합니다. 핀 고정된 공지가 먼저 표시되고, 그 다음 최신순으로 정렬됩니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 방") + }) + public ResponseEntity>> getAnnouncements( + @Parameter(description = "방 ID", required = true) @PathVariable Long roomId) { + + List announcements = announcementService.getAnnouncements(roomId); + + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success("공지사항 목록 조회 완료", announcements)); + } + + @GetMapping("/{announcementId}") + @Operation( + summary = "공지사항 단건 조회", + description = "특정 공지사항의 상세 정보를 조회합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 공지사항") + }) + public ResponseEntity> getAnnouncement( + @Parameter(description = "방 ID", required = true) @PathVariable Long roomId, + @Parameter(description = "공지사항 ID", required = true) @PathVariable Long announcementId) { + + RoomAnnouncementResponse announcement = announcementService.getAnnouncement(announcementId); + + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success("공지사항 조회 완료", announcement)); + } + + @PutMapping("/{announcementId}") + @Operation( + summary = "공지사항 수정", + description = "공지사항의 제목과 내용을 수정합니다. 방장만 수정 가능합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "수정 성공"), + @ApiResponse(responseCode = "403", description = "방장 권한 없음"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 공지사항"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + public ResponseEntity> updateAnnouncement( + @Parameter(description = "방 ID", required = true) @PathVariable Long roomId, + @Parameter(description = "공지사항 ID", required = true) @PathVariable Long announcementId, + @Valid @RequestBody UpdateAnnouncementRequest request) { + + Long userId = currentUser.getUserId(); + RoomAnnouncementResponse response = announcementService.updateAnnouncement( + announcementId, request.getTitle(), request.getContent(), userId); + + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success("공지사항 수정 완료", response)); + } + + @DeleteMapping("/{announcementId}") + @Operation( + summary = "공지사항 삭제", + description = "공지사항을 삭제합니다. 방장만 삭제 가능합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "삭제 성공"), + @ApiResponse(responseCode = "403", description = "방장 권한 없음"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 공지사항"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + public ResponseEntity> deleteAnnouncement( + @Parameter(description = "방 ID", required = true) @PathVariable Long roomId, + @Parameter(description = "공지사항 ID", required = true) @PathVariable Long announcementId) { + + Long userId = currentUser.getUserId(); + announcementService.deleteAnnouncement(announcementId, userId); + + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success("공지사항 삭제 완료", null)); + } + + @PutMapping("/{announcementId}/pin") + @Operation( + summary = "공지사항 핀 고정/해제", + description = "공지사항을 상단에 고정하거나 고정을 해제합니다. 방장만 실행 가능합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "핀 토글 성공"), + @ApiResponse(responseCode = "403", description = "방장 권한 없음"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 공지사항"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + public ResponseEntity> togglePin( + @Parameter(description = "방 ID", required = true) @PathVariable Long roomId, + @Parameter(description = "공지사항 ID", required = true) @PathVariable Long announcementId) { + + Long userId = currentUser.getUserId(); + RoomAnnouncementResponse response = announcementService.togglePin(announcementId, userId); + + String message = response.getIsPinned() ? "공지사항 핀 고정 완료" : "공지사항 핀 고정 해제 완료"; + + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success(message, response)); + } +} 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 342a6d9c..7ac34583 100644 --- a/src/main/java/com/back/domain/studyroom/controller/RoomController.java +++ b/src/main/java/com/back/domain/studyroom/controller/RoomController.java @@ -141,8 +141,9 @@ public ResponseEntity>> getAllRooms( Pageable pageable = PageRequest.of(page, size); Page rooms = roomService.getAllRooms(pageable); - // 모든 정보 공개 - List roomList = roomService.toRoomResponseList(rooms.getContent()); + // 비로그인 사용자도 조회 가능 (userId = null이면 isFavorite = false) + Long userId = currentUser.getUserIdOrNull(); + List roomList = roomService.toRoomResponseList(rooms.getContent(), userId); Map response = new HashMap<>(); response.put("rooms", roomList); @@ -173,7 +174,9 @@ public ResponseEntity>> getPublicRooms( Pageable pageable = PageRequest.of(page, size); Page rooms = roomService.getPublicRooms(includeInactive, pageable); - List roomList = roomService.toRoomResponseList(rooms.getContent()); + // 비로그인 사용자도 조회 가능 + Long userId = currentUser.getUserIdOrNull(); + List roomList = roomService.toRoomResponseList(rooms.getContent(), userId); Map response = new HashMap<>(); response.put("rooms", roomList); @@ -207,7 +210,7 @@ public ResponseEntity>> getMyPrivateRooms( Pageable pageable = PageRequest.of(page, size); Page rooms = roomService.getMyPrivateRooms(currentUserId, includeInactive, pageable); - List roomList = roomService.toRoomResponseList(rooms.getContent()); + List roomList = roomService.toRoomResponseList(rooms.getContent(), currentUserId); Map response = new HashMap<>(); response.put("rooms", roomList); @@ -240,7 +243,7 @@ public ResponseEntity>> getMyHostingRooms( Pageable pageable = PageRequest.of(page, size); Page rooms = roomService.getMyHostingRooms(currentUserId, pageable); - List roomList = roomService.toRoomResponseList(rooms.getContent()); + List roomList = roomService.toRoomResponseList(rooms.getContent(), currentUserId); Map response = new HashMap<>(); response.put("rooms", roomList); @@ -270,7 +273,9 @@ public ResponseEntity>> getRooms( Pageable pageable = PageRequest.of(page, size); Page rooms = roomService.getJoinableRooms(pageable); - List roomList = roomService.toRoomResponseList(rooms.getContent()); + // 비로그인 사용자도 조회 가능 + Long userId = currentUser.getUserIdOrNull(); + List roomList = roomService.toRoomResponseList(rooms.getContent(), userId); Map response = new HashMap<>(); response.put("rooms", roomList); @@ -303,7 +308,7 @@ public ResponseEntity> getRoomDetail( Room room = roomService.getRoomDetail(roomId, currentUserId); List members = roomService.getRoomMembers(roomId, currentUserId); - RoomDetailResponse response = roomService.toRoomDetailResponse(room, members); + RoomDetailResponse response = roomService.toRoomDetailResponse(room, members, currentUserId); return ResponseEntity .status(HttpStatus.OK) @@ -554,7 +559,9 @@ public ResponseEntity>> getPopularRooms( Pageable pageable = PageRequest.of(page, size); Page rooms = roomService.getPopularRooms(pageable); - List roomList = roomService.toRoomResponseList(rooms.getContent()); + // 비로그인 사용자도 조회 가능 + Long userId = currentUser.getUserIdOrNull(); + List roomList = roomService.toRoomResponseList(rooms.getContent(), userId); Map response = new HashMap<>(); response.put("rooms", roomList); diff --git a/src/main/java/com/back/domain/studyroom/controller/RoomFavoriteController.java b/src/main/java/com/back/domain/studyroom/controller/RoomFavoriteController.java new file mode 100644 index 00000000..b5147ade --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/controller/RoomFavoriteController.java @@ -0,0 +1,113 @@ +package com.back.domain.studyroom.controller; + +import com.back.domain.studyroom.dto.RoomFavoriteResponse; +import com.back.domain.studyroom.service.RoomFavoriteService; +import com.back.global.common.dto.RsData; +import com.back.global.security.user.CurrentUser; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 방 즐겨찾기 API 컨트롤러 + */ +@RestController +@RequestMapping("/api/rooms") +@RequiredArgsConstructor +@Tag(name = "Room Favorite API", description = "방 즐겨찾기 관련 API") +@SecurityRequirement(name = "Bearer Authentication") +public class RoomFavoriteController { + + private final RoomFavoriteService favoriteService; + private final CurrentUser currentUser; + + @PostMapping("/{roomId}/favorite") + @Operation( + summary = "즐겨찾기 추가", + description = "특정 방을 즐겨찾기에 추가합니다. 이미 추가된 경우 무시됩니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "즐겨찾기 추가 성공"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 방"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + public ResponseEntity> addFavorite( + @Parameter(description = "방 ID", required = true) @PathVariable Long roomId) { + + Long userId = currentUser.getUserId(); + favoriteService.addFavorite(roomId, userId); + + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success("즐겨찾기 추가 완료", null)); + } + + @DeleteMapping("/{roomId}/favorite") + @Operation( + summary = "즐겨찾기 제거", + description = "특정 방을 즐겨찾기에서 제거합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "즐겨찾기 제거 성공"), + @ApiResponse(responseCode = "404", description = "즐겨찾기되지 않은 방"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + public ResponseEntity> removeFavorite( + @Parameter(description = "방 ID", required = true) @PathVariable Long roomId) { + + Long userId = currentUser.getUserId(); + favoriteService.removeFavorite(roomId, userId); + + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success("즐겨찾기 제거 완료", null)); + } + + @GetMapping("/favorites") + @Operation( + summary = "내 즐겨찾기 목록 조회", + description = "내가 즐겨찾기한 모든 방 목록을 최신 즐겨찾기 순으로 조회합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + public ResponseEntity>> getMyFavorites() { + + Long userId = currentUser.getUserId(); + List favorites = favoriteService.getMyFavorites(userId); + + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success("즐겨찾기 목록 조회 완료", favorites)); + } + + @GetMapping("/{roomId}/favorite") + @Operation( + summary = "즐겨찾기 여부 확인", + description = "특정 방이 즐겨찾기되어 있는지 확인합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + public ResponseEntity> isFavorite( + @Parameter(description = "방 ID", required = true) @PathVariable Long roomId) { + + Long userId = currentUser.getUserId(); + boolean isFavorite = favoriteService.isFavorite(roomId, userId); + + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success("즐겨찾기 여부 조회 완료", isFavorite)); + } +} diff --git a/src/main/java/com/back/domain/studyroom/dto/CreateAnnouncementRequest.java b/src/main/java/com/back/domain/studyroom/dto/CreateAnnouncementRequest.java new file mode 100644 index 00000000..e0d0b57d --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/dto/CreateAnnouncementRequest.java @@ -0,0 +1,24 @@ +package com.back.domain.studyroom.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 공지사항 생성 요청 DTO + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class CreateAnnouncementRequest { + + @NotBlank(message = "공지사항 제목은 필수입니다") + @Size(max = 100, message = "제목은 100자 이내여야 합니다") + private String title; + + @NotBlank(message = "공지사항 내용은 필수입니다") + @Size(max = 5000, message = "내용은 5000자 이내여야 합니다") + private String content; +} diff --git a/src/main/java/com/back/domain/studyroom/dto/RoomAnnouncementResponse.java b/src/main/java/com/back/domain/studyroom/dto/RoomAnnouncementResponse.java new file mode 100644 index 00000000..41822e1d --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/dto/RoomAnnouncementResponse.java @@ -0,0 +1,38 @@ +package com.back.domain.studyroom.dto; + +import com.back.domain.studyroom.entity.RoomAnnouncement; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +/** + * 방 공지사항 응답 DTO + */ +@Getter +@Builder +public class RoomAnnouncementResponse { + private Long id; + private String title; + private String content; + private Boolean isPinned; + private LocalDateTime pinnedAt; + private String createdByNickname; + private Long createdById; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public static RoomAnnouncementResponse from(RoomAnnouncement announcement) { + return RoomAnnouncementResponse.builder() + .id(announcement.getId()) + .title(announcement.getTitle()) + .content(announcement.getContent()) + .isPinned(announcement.isPinned()) + .pinnedAt(announcement.getPinnedAt()) + .createdByNickname(announcement.getCreatedBy().getNickname()) + .createdById(announcement.getCreatedBy().getId()) + .createdAt(announcement.getCreatedAt()) + .updatedAt(announcement.getUpdatedAt()) + .build(); + } +} 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 b2eacba8..4be95efd 100644 --- a/src/main/java/com/back/domain/studyroom/dto/RoomDetailResponse.java +++ b/src/main/java/com/back/domain/studyroom/dto/RoomDetailResponse.java @@ -24,9 +24,10 @@ public class RoomDetailResponse { private boolean allowScreenShare; private String createdBy; private LocalDateTime createdAt; + private Boolean isFavorite; private List members; - public static RoomDetailResponse of(Room room, long currentParticipants, List members) { + public static RoomDetailResponse of(Room room, long currentParticipants, List members, boolean isFavorite) { return RoomDetailResponse.builder() .roomId(room.getId()) .title(room.getTitle()) @@ -41,6 +42,7 @@ public static RoomDetailResponse of(Room room, long currentParticipants, List { + + /** + * 특정 방의 공지사항 조회 (작성자 정보 포함, 핀 고정 우선 + 최신순) + */ + @Query("SELECT a FROM RoomAnnouncement a " + + "JOIN FETCH a.createdBy " + + "WHERE a.room.id = :roomId " + + "ORDER BY a.isPinned DESC, a.createdAt DESC") + List findByRoomIdWithCreator(@Param("roomId") Long roomId); + + /** + * 공지사항 단건 조회 (작성자 정보 포함) + */ + @Query("SELECT a FROM RoomAnnouncement a " + + "JOIN FETCH a.createdBy " + + "WHERE a.id = :announcementId") + Optional findByIdWithCreator(@Param("announcementId") Long announcementId); +} diff --git a/src/main/java/com/back/domain/studyroom/repository/RoomFavoriteRepository.java b/src/main/java/com/back/domain/studyroom/repository/RoomFavoriteRepository.java new file mode 100644 index 00000000..1d19d565 --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/repository/RoomFavoriteRepository.java @@ -0,0 +1,46 @@ +package com.back.domain.studyroom.repository; + +import com.back.domain.studyroom.entity.RoomFavorite; +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; +import java.util.Optional; +import java.util.Set; + +public interface RoomFavoriteRepository extends JpaRepository { + + /** + * 특정 사용자의 특정 방 즐겨찾기 조회 + */ + Optional findByUserIdAndRoomId(Long userId, Long roomId); + + /** + * 특정 사용자의 모든 즐겨찾기 조회 (최신순) + * N+1 방지를 위해 Room과 JOIN FETCH + */ + @Query("SELECT rf FROM RoomFavorite rf " + + "JOIN FETCH rf.room r " + + "LEFT JOIN FETCH r.createdBy " + + "WHERE rf.user.id = :userId " + + "ORDER BY rf.createdAt DESC") + List findByUserIdWithRoom(@Param("userId") Long userId); + + /** + * 즐겨찾기 존재 여부 확인 + */ + boolean existsByUserIdAndRoomId(Long userId, Long roomId); + + /** + * 여러 방에 대한 즐겨찾기 여부 일괄 조회 (N+1 방지용) + */ + @Query("SELECT rf.room.id FROM RoomFavorite rf " + + "WHERE rf.user.id = :userId AND rf.room.id IN :roomIds") + Set findFavoriteRoomIds(@Param("userId") Long userId, @Param("roomIds") List roomIds); + + /** + * 특정 방의 즐겨찾기 개수 (추후 통계용) + */ + long countByRoomId(Long roomId); +} diff --git a/src/main/java/com/back/domain/studyroom/service/RoomAnnouncementService.java b/src/main/java/com/back/domain/studyroom/service/RoomAnnouncementService.java new file mode 100644 index 00000000..71e74a1c --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/service/RoomAnnouncementService.java @@ -0,0 +1,147 @@ +package com.back.domain.studyroom.service; + +import com.back.domain.studyroom.dto.RoomAnnouncementResponse; +import com.back.domain.studyroom.entity.Room; +import com.back.domain.studyroom.entity.RoomAnnouncement; +import com.back.domain.studyroom.repository.RoomAnnouncementRepository; +import com.back.domain.studyroom.repository.RoomRepository; +import com.back.domain.user.common.entity.User; +import com.back.domain.user.common.repository.UserRepository; +import com.back.global.exception.CustomException; +import com.back.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * 방 공지사항 서비스 + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Slf4j +public class RoomAnnouncementService { + + private final RoomAnnouncementRepository announcementRepository; + private final RoomRepository roomRepository; + private final UserRepository userRepository; + + /** + * 공지사항 생성 (방장만 가능) + */ + @Transactional + public RoomAnnouncementResponse createAnnouncement(Long roomId, String title, String content, Long userId) { + Room room = roomRepository.findById(roomId) + .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); + + // 방장 권한 확인 + if (!room.isOwner(userId)) { + throw new CustomException(ErrorCode.FORBIDDEN); + } + + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + RoomAnnouncement announcement = RoomAnnouncement.create(room, user, title, content); + RoomAnnouncement saved = announcementRepository.save(announcement); + + log.info("공지사항 생성 - RoomId: {}, AnnouncementId: {}, UserId: {}", roomId, saved.getId(), userId); + + return RoomAnnouncementResponse.from(saved); + } + + /** + * 공지사항 수정 (방장만 가능) + */ + @Transactional + public RoomAnnouncementResponse updateAnnouncement(Long announcementId, String title, String content, Long userId) { + RoomAnnouncement announcement = announcementRepository.findByIdWithCreator(announcementId) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND)); + + Room room = announcement.getRoom(); + + // 방장 권한 확인 + if (!room.isOwner(userId)) { + throw new CustomException(ErrorCode.FORBIDDEN); + } + + announcement.update(title, content); + + log.info("공지사항 수정 - AnnouncementId: {}, UserId: {}", announcementId, userId); + + return RoomAnnouncementResponse.from(announcement); + } + + /** + * 공지사항 삭제 (방장만 가능) + */ + @Transactional + public void deleteAnnouncement(Long announcementId, Long userId) { + RoomAnnouncement announcement = announcementRepository.findById(announcementId) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND)); + + Room room = announcement.getRoom(); + + // 방장 권한 확인 + if (!room.isOwner(userId)) { + throw new CustomException(ErrorCode.FORBIDDEN); + } + + announcementRepository.delete(announcement); + + log.info("공지사항 삭제 - AnnouncementId: {}, UserId: {}", announcementId, userId); + } + + /** + * 공지사항 목록 조회 (핀 고정 우선, 최신순) + */ + public List getAnnouncements(Long roomId) { + // 방 존재 확인 + if (!roomRepository.existsById(roomId)) { + throw new CustomException(ErrorCode.ROOM_NOT_FOUND); + } + + List announcements = announcementRepository.findByRoomIdWithCreator(roomId); + + return announcements.stream() + .map(RoomAnnouncementResponse::from) + .collect(Collectors.toList()); + } + + /** + * 공지사항 단건 조회 + */ + public RoomAnnouncementResponse getAnnouncement(Long announcementId) { + RoomAnnouncement announcement = announcementRepository.findByIdWithCreator(announcementId) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND)); + + return RoomAnnouncementResponse.from(announcement); + } + + /** + * 공지사항 핀 고정/해제 토글 (방장만 가능) + */ + @Transactional + public RoomAnnouncementResponse togglePin(Long announcementId, Long userId) { + RoomAnnouncement announcement = announcementRepository.findByIdWithCreator(announcementId) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND)); + + Room room = announcement.getRoom(); + + // 방장 권한 확인 + if (!room.isOwner(userId)) { + throw new CustomException(ErrorCode.FORBIDDEN); + } + + announcement.togglePin(); + + log.info("공지사항 핀 토글 - AnnouncementId: {}, isPinned: {}, UserId: {}", + announcementId, announcement.isPinned(), userId); + + return RoomAnnouncementResponse.from(announcement); + } +} diff --git a/src/main/java/com/back/domain/studyroom/service/RoomFavoriteService.java b/src/main/java/com/back/domain/studyroom/service/RoomFavoriteService.java new file mode 100644 index 00000000..dff1b858 --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/service/RoomFavoriteService.java @@ -0,0 +1,114 @@ +package com.back.domain.studyroom.service; + +import com.back.domain.studyroom.dto.RoomFavoriteResponse; +import com.back.domain.studyroom.entity.Room; +import com.back.domain.studyroom.entity.RoomFavorite; +import com.back.domain.studyroom.repository.RoomFavoriteRepository; +import com.back.domain.studyroom.repository.RoomRepository; +import com.back.domain.user.common.entity.User; +import com.back.domain.user.common.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.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 방 즐겨찾기 서비스 + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Slf4j +public class RoomFavoriteService { + + private final RoomFavoriteRepository favoriteRepository; + private final RoomRepository roomRepository; + private final UserRepository userRepository; + private final RoomParticipantService roomParticipantService; + + /** + * 즐겨찾기 추가 + */ + @Transactional + public void addFavorite(Long roomId, Long userId) { + // 이미 즐겨찾기 되어있는지 확인 + if (favoriteRepository.existsByUserIdAndRoomId(userId, roomId)) { + log.info("이미 즐겨찾기된 방 - UserId: {}, RoomId: {}", userId, roomId); + return; // Idempotent: 이미 있으면 무시 + } + + Room room = roomRepository.findById(roomId) + .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); + + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + RoomFavorite favorite = RoomFavorite.create(user, room); + favoriteRepository.save(favorite); + + log.info("즐겨찾기 추가 - UserId: {}, RoomId: {}", userId, roomId); + } + + /** + * 즐겨찾기 제거 + */ + @Transactional + public void removeFavorite(Long roomId, Long userId) { + RoomFavorite favorite = favoriteRepository.findByUserIdAndRoomId(userId, roomId) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND)); + + favoriteRepository.delete(favorite); + + log.info("즐겨찾기 제거 - UserId: {}, RoomId: {}", userId, roomId); + } + + /** + * 내 즐겨찾기 목록 조회 + */ + public List getMyFavorites(Long userId) { + List favorites = favoriteRepository.findByUserIdWithRoom(userId); + + if (favorites.isEmpty()) { + return List.of(); + } + + // 방 ID 리스트 추출 + List roomIds = favorites.stream() + .map(f -> f.getRoom().getId()) + .collect(Collectors.toList()); + + // Redis에서 참가자 수 일괄 조회 (N+1 방지) + Map participantCounts = roomParticipantService.getParticipantCounts(roomIds); + + // 응답 생성 + return favorites.stream() + .map(favorite -> RoomFavoriteResponse.of( + favorite.getRoom(), + participantCounts.getOrDefault(favorite.getRoom().getId(), 0L), + favorite.getCreatedAt() // 즐겨찾기한 시간 + )) + .collect(Collectors.toList()); + } + + /** + * 특정 방의 즐겨찾기 여부 확인 + */ + public boolean isFavorite(Long roomId, Long userId) { + return favoriteRepository.existsByUserIdAndRoomId(userId, roomId); + } + + /** + * 여러 방의 즐겨찾기 여부 일괄 확인 (N+1 방지) + */ + public Set getFavoriteRoomIds(List roomIds, Long userId) { + return favoriteRepository.findFavoriteRoomIds(userId, 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 c2cfceeb..c93654fe 100644 --- a/src/main/java/com/back/domain/studyroom/service/RoomService.java +++ b/src/main/java/com/back/domain/studyroom/service/RoomService.java @@ -4,6 +4,8 @@ import com.back.domain.notification.event.studyroom.MemberRoleChangedEvent; import com.back.domain.notification.event.studyroom.OwnerTransferredEvent; import com.back.domain.studyroom.config.StudyRoomProperties; +import com.back.domain.studyroom.dto.RoomDetailResponse; +import com.back.domain.studyroom.dto.RoomMemberResponse; import com.back.domain.studyroom.dto.RoomResponse; import com.back.domain.studyroom.entity.*; import com.back.domain.studyroom.repository.*; @@ -53,6 +55,7 @@ public class RoomService { private final ApplicationEventPublisher eventPublisher; private final AvatarService avatarService; private final RoomThumbnailService roomThumbnailService; + private final RoomFavoriteService roomFavoriteService; /** * 방 생성 메서드 @@ -264,9 +267,12 @@ public Page getMyHostingRooms(Long userId, Pageable pageable) { /** * 모든 방을 RoomResponse로 변환 (비공개 방 마스킹 포함) * @param rooms 방 목록 + * @param userId 현재 사용자 ID (비로그인이면 null) * @return 마스킹된 RoomResponse 리스트 */ - public java.util.List toRoomResponseListWithMasking(java.util.List rooms) { + public java.util.List toRoomResponseListWithMasking( + java.util.List rooms, Long userId) { + java.util.List roomIds = rooms.stream() .map(Room::getId) .collect(java.util.stream.Collectors.toList()); @@ -274,6 +280,11 @@ public java.util.List toRoomResponseListWithMasking(java.util.List // Redis Pipeline으로 일괄 조회 (N+1 해결) java.util.Map participantCounts = roomParticipantService.getParticipantCounts(roomIds); + // 즐겨찾기 여부 일괄 조회 (로그인 사용자만) + java.util.Set favoriteRoomIds = userId != null + ? roomFavoriteService.getFavoriteRoomIds(roomIds, userId) + : java.util.Set.of(); + return rooms.stream() .map(room -> { long count = participantCounts.getOrDefault(room.getId(), 0L); @@ -283,8 +294,8 @@ public java.util.List toRoomResponseListWithMasking(java.util.List return RoomResponse.fromMasked(room); } - // 공개 방은 일반 버전 반환 - return RoomResponse.from(room, count); + // 공개 방은 일반 버전 반환 (즐겨찾기 포함) + return RoomResponse.from(room, count, favoriteRoomIds.contains(room.getId())); }) .collect(java.util.stream.Collectors.toList()); } @@ -710,41 +721,57 @@ public void kickMember(Long roomId, Long targetUserId, Long requesterId) { */ 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); + return com.back.domain.studyroom.dto.RoomResponse.from(room, onlineCount, false); + } + + /** + * RoomResponse 생성 (즐겨찾기 여부 포함) + */ + public com.back.domain.studyroom.dto.RoomResponse toRoomResponse(Room room, Long userId) { + long onlineCount = roomParticipantService.getParticipantCount(room.getId()); + boolean isFavorite = userId != null && roomFavoriteService.isFavorite(room.getId(), userId); + return com.back.domain.studyroom.dto.RoomResponse.from(room, onlineCount, isFavorite); } /** * RoomResponse 리스트 생성 (일괄 조회로 N+1 방지) + * 비로그인 사용자는 userId = null, 모든 방의 isFavorite = false */ - public java.util.List toRoomResponseList(java.util.List rooms) { + public java.util.List toRoomResponseList( + java.util.List rooms, Long userId) { + java.util.List roomIds = rooms.stream() .map(Room::getId) .collect(java.util.stream.Collectors.toList()); - // Redis Pipeline으로 일괄 조회 (N+1 해결) + // Redis Pipeline으로 참가자 수 일괄 조회 (N+1 해결) java.util.Map participantCounts = roomParticipantService.getParticipantCounts(roomIds); + // 즐겨찾기 여부 일괄 조회 (로그인 사용자만) + java.util.Set favoriteRoomIds = userId != null + ? roomFavoriteService.getFavoriteRoomIds(roomIds, userId) + : java.util.Set.of(); + return rooms.stream() .map(room -> com.back.domain.studyroom.dto.RoomResponse.from( room, - participantCounts.getOrDefault(room.getId(), 0L) + participantCounts.getOrDefault(room.getId(), 0L), + favoriteRoomIds.contains(room.getId()) // 즐겨찾기 여부 )) .collect(java.util.stream.Collectors.toList()); } - + /** - * RoomDetailResponse 생성 (Redis에서 실시간 참가자 수 조회) + * RoomDetailResponse 생성 (즐겨찾기 여부 포함) */ - public com.back.domain.studyroom.dto.RoomDetailResponse toRoomDetailResponse( - Room room, - java.util.List members) { + public RoomDetailResponse toRoomDetailResponse(Room room, List members, Long userId) { long onlineCount = roomParticipantService.getParticipantCount(room.getId()); + boolean isFavorite = userId != null && roomFavoriteService.isFavorite(room.getId(), userId); - java.util.List memberResponses = members.stream() - .map(com.back.domain.studyroom.dto.RoomMemberResponse::from) - .collect(java.util.stream.Collectors.toList()); + // RoomMember를 RoomMemberResponse로 변환 + List memberResponses = toRoomMemberResponseList(room.getId(), members); - return com.back.domain.studyroom.dto.RoomDetailResponse.of(room, onlineCount, memberResponses); + return RoomDetailResponse.of(room, onlineCount, memberResponses, isFavorite); } /** diff --git a/src/test/java/com/back/domain/board/post/controller/PostControllerTest.java b/src/test/java/com/back/domain/board/post/controller/PostControllerTest.java index dadd478d..38c82242 100644 --- a/src/test/java/com/back/domain/board/post/controller/PostControllerTest.java +++ b/src/test/java/com/back/domain/board/post/controller/PostControllerTest.java @@ -20,11 +20,11 @@ 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.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; import org.springframework.mock.web.MockMultipartFile; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; import org.springframework.transaction.annotation.Transactional; @@ -66,7 +66,7 @@ class PostControllerTest { @Autowired private ObjectMapper objectMapper; - @MockBean + @MockitoBean private FileService fileService; private String generateAccessToken(User user) { diff --git a/src/test/java/com/back/domain/board/post/service/PostServiceTest.java b/src/test/java/com/back/domain/board/post/service/PostServiceTest.java index 977f7d99..5b161033 100644 --- a/src/test/java/com/back/domain/board/post/service/PostServiceTest.java +++ b/src/test/java/com/back/domain/board/post/service/PostServiceTest.java @@ -26,12 +26,12 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.mock.web.MockMultipartFile; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.transaction.annotation.Transactional; import java.util.List; @@ -61,7 +61,7 @@ class PostServiceTest { @Autowired private AttachmentMappingRepository attachmentMappingRepository; - @MockBean + @MockitoBean private FileService fileService; // ====================== 게시글 생성 테스트 ====================== 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 9a867b45..9040577f 100644 --- a/src/test/java/com/back/domain/studyroom/controller/RoomControllerTest.java +++ b/src/test/java/com/back/domain/studyroom/controller/RoomControllerTest.java @@ -181,6 +181,8 @@ void leaveRoom() { @DisplayName("공개 방 목록 조회 API 테스트") void getRooms() { // given + given(currentUser.getUserIdOrNull()).willReturn(null); // 비로그인 사용자 + Page roomPage = new PageImpl<>( Arrays.asList(testRoom), PageRequest.of(0, 20), @@ -188,8 +190,8 @@ void getRooms() { ); given(roomService.getJoinableRooms(any())).willReturn(roomPage); - List roomResponses = Arrays.asList(RoomResponse.from(testRoom, 1)); - given(roomService.toRoomResponseList(anyList())).willReturn(roomResponses); + List roomResponses = Arrays.asList(RoomResponse.from(testRoom, 1, false)); + given(roomService.toRoomResponseList(anyList(), isNull())).willReturn(roomResponses); // when ResponseEntity>> response = roomController.getRooms(0, 20); @@ -201,7 +203,7 @@ void getRooms() { assertThat(response.getBody().getData().get("rooms")).isNotNull(); verify(roomService, times(1)).getJoinableRooms(any()); - verify(roomService, times(1)).toRoomResponseList(anyList()); + verify(roomService, times(1)).toRoomResponseList(anyList(), isNull()); } @Test @@ -216,9 +218,10 @@ void getRoomDetail() { RoomDetailResponse roomDetailResponse = RoomDetailResponse.of( testRoom, 1, - Arrays.asList(RoomMemberResponse.from(testMember)) + Arrays.asList(RoomMemberResponse.from(testMember)), + false // isFavorite ); - given(roomService.toRoomDetailResponse(any(Room.class), anyList())).willReturn(roomDetailResponse); + given(roomService.toRoomDetailResponse(any(Room.class), anyList(), eq(1L))).willReturn(roomDetailResponse); // when ResponseEntity> response = roomController.getRoomDetail(1L); @@ -232,7 +235,7 @@ void getRoomDetail() { verify(currentUser, times(1)).getUserIdOrNull(); 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()); + verify(roomService, times(1)).toRoomDetailResponse(any(Room.class), anyList(), eq(1L)); } @Test @@ -353,6 +356,8 @@ void getRoomMembers() { @DisplayName("인기 방 목록 조회 API 테스트") void getPopularRooms() { // given + given(currentUser.getUserIdOrNull()).willReturn(null); // 비로그인 사용자 + Page roomPage = new PageImpl<>( Arrays.asList(testRoom), PageRequest.of(0, 20), @@ -360,8 +365,8 @@ void getPopularRooms() { ); given(roomService.getPopularRooms(any())).willReturn(roomPage); - List roomResponses = Arrays.asList(RoomResponse.from(testRoom, 1)); - given(roomService.toRoomResponseList(anyList())).willReturn(roomResponses); + List roomResponses = Arrays.asList(RoomResponse.from(testRoom, 1, false)); + given(roomService.toRoomResponseList(anyList(), isNull())).willReturn(roomResponses); // when ResponseEntity>> response = roomController.getPopularRooms(0, 20); @@ -373,8 +378,7 @@ void getPopularRooms() { assertThat(response.getBody().getData().get("rooms")).isNotNull(); verify(roomService, times(1)).getPopularRooms(any()); - verify(roomService, times(1)).toRoomResponseList(anyList()); - + verify(roomService, times(1)).toRoomResponseList(anyList(), isNull()); } @Test diff --git a/src/test/java/com/back/domain/studyroom/controller/RoomFavoriteControllerTest.java b/src/test/java/com/back/domain/studyroom/controller/RoomFavoriteControllerTest.java new file mode 100644 index 00000000..dacb8eeb --- /dev/null +++ b/src/test/java/com/back/domain/studyroom/controller/RoomFavoriteControllerTest.java @@ -0,0 +1,187 @@ +package com.back.domain.studyroom.controller; + +import com.back.domain.studyroom.dto.RoomFavoriteResponse; +import com.back.domain.studyroom.entity.RoomStatus; +import com.back.domain.studyroom.service.RoomFavoriteService; +import com.back.global.exception.CustomException; +import com.back.global.exception.ErrorCode; +import com.back.global.security.user.CurrentUser; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("RoomFavoriteController 테스트") +class RoomFavoriteControllerTest { + + @Mock + private RoomFavoriteService favoriteService; + + @Mock + private CurrentUser currentUser; + + @InjectMocks + private RoomFavoriteController controller; + + @Test + @DisplayName("즐겨찾기 추가 - 성공") + void addFavorite_Success() { + // given + Long userId = 1L; + Long roomId = 1L; + given(currentUser.getUserId()).willReturn(userId); + doNothing().when(favoriteService).addFavorite(roomId, userId); + + // when + ResponseEntity response = controller.addFavorite(roomId); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + verify(favoriteService, times(1)).addFavorite(roomId, userId); + } + + @Test + @DisplayName("즐겨찾기 추가 - 존재하지 않는 방") + void addFavorite_RoomNotFound() { + // given + Long userId = 1L; + Long roomId = 999L; + given(currentUser.getUserId()).willReturn(userId); + doThrow(new CustomException(ErrorCode.ROOM_NOT_FOUND)) + .when(favoriteService).addFavorite(roomId, userId); + + // when & then + assertThatThrownBy(() -> controller.addFavorite(roomId)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.ROOM_NOT_FOUND); + } + + @Test + @DisplayName("즐겨찾기 제거 - 성공") + void removeFavorite_Success() { + // given + Long userId = 1L; + Long roomId = 1L; + given(currentUser.getUserId()).willReturn(userId); + doNothing().when(favoriteService).removeFavorite(roomId, userId); + + // when + ResponseEntity response = controller.removeFavorite(roomId); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + verify(favoriteService, times(1)).removeFavorite(roomId, userId); + } + + @Test + @DisplayName("즐겨찾기 제거 - 존재하지 않음") + void removeFavorite_NotFound() { + // given + Long userId = 1L; + Long roomId = 999L; + given(currentUser.getUserId()).willReturn(userId); + doThrow(new CustomException(ErrorCode.NOT_FOUND)) + .when(favoriteService).removeFavorite(roomId, userId); + + // when & then + assertThatThrownBy(() -> controller.removeFavorite(roomId)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.NOT_FOUND); + } + + @Test + @DisplayName("내 즐겨찾기 목록 조회 - 성공") + void getMyFavorites_Success() { + // given + Long userId = 1L; + List favorites = List.of( + RoomFavoriteResponse.builder() + .roomId(1L) + .title("테스트 방 1") + .currentParticipants(5) + .maxParticipants(10) + .status(RoomStatus.ACTIVE) + .favoritedAt(LocalDateTime.now()) + .build(), + RoomFavoriteResponse.builder() + .roomId(2L) + .title("테스트 방 2") + .currentParticipants(3) + .maxParticipants(8) + .status(RoomStatus.WAITING) + .favoritedAt(LocalDateTime.now()) + .build() + ); + + given(currentUser.getUserId()).willReturn(userId); + given(favoriteService.getMyFavorites(userId)).willReturn(favorites); + + // when + ResponseEntity response = controller.getMyFavorites(); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + verify(favoriteService, times(1)).getMyFavorites(userId); + } + + @Test + @DisplayName("내 즐겨찾기 목록 조회 - 빈 목록") + void getMyFavorites_Empty() { + // given + Long userId = 1L; + given(currentUser.getUserId()).willReturn(userId); + given(favoriteService.getMyFavorites(userId)).willReturn(List.of()); + + // when + ResponseEntity response = controller.getMyFavorites(); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + verify(favoriteService, times(1)).getMyFavorites(userId); + } + + @Test + @DisplayName("즐겨찾기 여부 확인 - true") + void isFavorite_True() { + // given + Long userId = 1L; + Long roomId = 1L; + given(currentUser.getUserId()).willReturn(userId); + given(favoriteService.isFavorite(roomId, userId)).willReturn(true); + + // when + ResponseEntity response = controller.isFavorite(roomId); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + verify(favoriteService, times(1)).isFavorite(roomId, userId); + } + + @Test + @DisplayName("즐겨찾기 여부 확인 - false") + void isFavorite_False() { + // given + Long userId = 1L; + Long roomId = 999L; + given(currentUser.getUserId()).willReturn(userId); + given(favoriteService.isFavorite(roomId, userId)).willReturn(false); + + // when + ResponseEntity response = controller.isFavorite(roomId); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + verify(favoriteService, times(1)).isFavorite(roomId, userId); + } +} diff --git a/src/test/java/com/back/domain/studyroom/service/RoomFavoriteServiceTest.java b/src/test/java/com/back/domain/studyroom/service/RoomFavoriteServiceTest.java new file mode 100644 index 00000000..fe9826ab --- /dev/null +++ b/src/test/java/com/back/domain/studyroom/service/RoomFavoriteServiceTest.java @@ -0,0 +1,225 @@ +package com.back.domain.studyroom.service; + +import com.back.domain.studyroom.dto.RoomFavoriteResponse; +import com.back.domain.studyroom.entity.Room; +import com.back.domain.studyroom.entity.RoomFavorite; +import com.back.domain.studyroom.repository.RoomFavoriteRepository; +import com.back.domain.studyroom.repository.RoomRepository; +import com.back.domain.user.common.entity.User; +import com.back.domain.user.common.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; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.*; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("RoomFavoriteService 테스트") +class RoomFavoriteServiceTest { + + @Mock + private RoomFavoriteRepository favoriteRepository; + + @Mock + private RoomRepository roomRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private RoomParticipantService roomParticipantService; + + @InjectMocks + private RoomFavoriteService favoriteService; + + private User testUser; + private Room testRoom; + private RoomFavorite testFavorite; + + @BeforeEach + void setUp() { + testUser = User.builder() + .id(1L) + .username("testuser") + .build(); + + testRoom = Room.create( + "테스트 방", + "설명", + false, + null, + 10, + testUser, + null, + true, + null + ); + setRoomId(testRoom, 1L); + + testFavorite = RoomFavorite.create(testUser, testRoom); + } + + private void setRoomId(Room room, Long id) { + try { + java.lang.reflect.Field idField = room.getClass().getSuperclass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(room, id); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + @DisplayName("즐겨찾기 추가 - 성공") + void addFavorite_Success() { + // given + given(favoriteRepository.existsByUserIdAndRoomId(1L, 1L)).willReturn(false); + given(roomRepository.findById(1L)).willReturn(Optional.of(testRoom)); + given(userRepository.findById(1L)).willReturn(Optional.of(testUser)); + given(favoriteRepository.save(any(RoomFavorite.class))).willReturn(testFavorite); + + // when + favoriteService.addFavorite(1L, 1L); + + // then + verify(favoriteRepository, times(1)).existsByUserIdAndRoomId(1L, 1L); + verify(favoriteRepository, times(1)).save(any(RoomFavorite.class)); + } + + @Test + @DisplayName("즐겨찾기 추가 - 이미 존재하면 무시 (Idempotent)") + void addFavorite_AlreadyExists_Ignore() { + // given + given(favoriteRepository.existsByUserIdAndRoomId(1L, 1L)).willReturn(true); + + // when + favoriteService.addFavorite(1L, 1L); + + // then + verify(favoriteRepository, times(1)).existsByUserIdAndRoomId(1L, 1L); + verify(favoriteRepository, never()).save(any()); + } + + @Test + @DisplayName("즐겨찾기 추가 - 존재하지 않는 방") + void addFavorite_RoomNotFound() { + // given + given(favoriteRepository.existsByUserIdAndRoomId(1L, 999L)).willReturn(false); + given(roomRepository.findById(999L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> favoriteService.addFavorite(999L, 1L)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.ROOM_NOT_FOUND); + } + + @Test + @DisplayName("즐겨찾기 제거 - 성공") + void removeFavorite_Success() { + // given + given(favoriteRepository.findByUserIdAndRoomId(1L, 1L)) + .willReturn(Optional.of(testFavorite)); + + // when + favoriteService.removeFavorite(1L, 1L); + + // then + verify(favoriteRepository, times(1)).delete(testFavorite); + } + + @Test + @DisplayName("즐겨찾기 제거 - 존재하지 않음") + void removeFavorite_NotFound() { + // given + given(favoriteRepository.findByUserIdAndRoomId(1L, 999L)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> favoriteService.removeFavorite(999L, 1L)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.NOT_FOUND); + } + + @Test + @DisplayName("내 즐겨찾기 목록 조회 - 성공") + void getMyFavorites_Success() { + // given + List favorites = List.of(testFavorite); + given(favoriteRepository.findByUserIdWithRoom(1L)).willReturn(favorites); + given(roomParticipantService.getParticipantCounts(anyList())) + .willReturn(Map.of(1L, 5L)); + + // when + List result = favoriteService.getMyFavorites(1L); + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0).getRoomId()).isEqualTo(1L); + assertThat(result.get(0).getCurrentParticipants()).isEqualTo(5); + } + + @Test + @DisplayName("내 즐겨찾기 목록 조회 - 빈 목록") + void getMyFavorites_Empty() { + // given + given(favoriteRepository.findByUserIdWithRoom(1L)).willReturn(List.of()); + + // when + List result = favoriteService.getMyFavorites(1L); + + // then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("즐겨찾기 여부 확인 - true") + void isFavorite_True() { + // given + given(favoriteRepository.existsByUserIdAndRoomId(1L, 1L)).willReturn(true); + + // when + boolean result = favoriteService.isFavorite(1L, 1L); + + // then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("즐겨찾기 여부 확인 - false") + void isFavorite_False() { + // given + given(favoriteRepository.existsByUserIdAndRoomId(1L, 999L)).willReturn(false); + + // when + boolean result = favoriteService.isFavorite(999L, 1L); + + // then + assertThat(result).isFalse(); + } + + @Test + @DisplayName("여러 방의 즐겨찾기 여부 일괄 조회") + void getFavoriteRoomIds_Success() { + // given + List roomIds = List.of(1L, 2L, 3L); + Set favoriteIds = Set.of(1L, 3L); + given(favoriteRepository.findFavoriteRoomIds(1L, roomIds)).willReturn(favoriteIds); + + // when + Set result = favoriteService.getFavoriteRoomIds(roomIds, 1L); + + // then + assertThat(result).containsExactlyInAnyOrder(1L, 3L); + } +} diff --git a/src/test/java/com/back/domain/user/account/service/AccountServiceTest.java b/src/test/java/com/back/domain/user/account/service/AccountServiceTest.java index 6fb1952b..ab94f709 100644 --- a/src/test/java/com/back/domain/user/account/service/AccountServiceTest.java +++ b/src/test/java/com/back/domain/user/account/service/AccountServiceTest.java @@ -28,13 +28,13 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.mock.web.MockMultipartFile; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; @@ -73,7 +73,7 @@ class AccountServiceTest { @Autowired private PasswordEncoder passwordEncoder; - @MockBean + @MockitoBean private FileService fileService; private MultipartFile mockMultipartFile(String filename) {