diff --git a/src/main/java/com/back/domain/studyroom/controller/RoomGuestbookController.java b/src/main/java/com/back/domain/studyroom/controller/RoomGuestbookController.java new file mode 100644 index 00000000..4bdb395f --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/controller/RoomGuestbookController.java @@ -0,0 +1,262 @@ +package com.back.domain.studyroom.controller; + +import com.back.domain.studyroom.dto.*; +import com.back.domain.studyroom.service.RoomGuestbookService; +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.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.Map; + +/** + * 방명록 관리 Controller + * - 방명록 CRUD + * - 이모지 반응 추가/제거 + * - 개인별 핀 기능 + */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/room/{roomId}/guestbook") +@Tag(name = "Room Guestbook", description = "방명록 관리 API - 방명록 작성, 조회, 수정, 삭제 및 이모지 반응, 개인 핀 기능") +public class RoomGuestbookController { + + private final RoomGuestbookService guestbookService; + private final CurrentUser currentUser; + + /** + * 방명록 목록 조회 (페이징) + * + * @param roomId 방 ID + * @param page 페이지 번호 (0부터 시작) + * @param size 페이지 크기 + * @return 방명록 목록 + */ + @GetMapping + @Operation( + summary = "방명록 목록 조회", + description = "특정 방의 방명록 목록을 조회합니다. 로그인한 사용자가 핀한 방명록이 최상단에 표시됩니다. 페이징을 지원하며, 각 방명록의 이모지 반응과 핀 상태가 포함됩니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "방명록 목록 조회 성공"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 방") + }) + public ResponseEntity>> getGuestbooks( + @PathVariable Long roomId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + + Long currentUserId = currentUser.getUserIdOrNull(); + Pageable pageable = PageRequest.of(page, size); + + Page guestbooks = guestbookService.getGuestbooks(roomId, currentUserId, pageable); + + Map response = new HashMap<>(); + response.put("guestbooks", guestbooks.getContent()); + response.put("totalPages", guestbooks.getTotalPages()); + response.put("totalElements", guestbooks.getTotalElements()); + response.put("currentPage", guestbooks.getNumber()); + response.put("pageSize", guestbooks.getSize()); + + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success("방명록 목록 조회 성공", response)); + } + + /** + * 방명록 단건 조회 + * + * @param roomId 방 ID + * @param guestbookId 방명록 ID + * @return 방명록 상세 정보 + */ + @GetMapping("/{guestbookId}") + @Operation( + summary = "방명록 단건 조회", + description = "특정 방명록의 상세 정보를 조회합니다. 작성자 정보, 내용, 이모지 반응, 핀 상태 등이 포함됩니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "방명록 조회 성공"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 방명록") + }) + public ResponseEntity> getGuestbook( + @PathVariable Long roomId, + @PathVariable Long guestbookId) { + + Long currentUserId = currentUser.getUserIdOrNull(); + GuestbookResponse guestbook = guestbookService.getGuestbook(guestbookId, currentUserId); + + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success("방명록 조회 성공", guestbook)); + } + + /** + * 방명록 작성 + * + * @param roomId 방 ID + * @param request 방명록 내용 + * @return 생성된 방명록 + */ + @PostMapping + @Operation( + summary = "방명록 작성", + description = "특정 방에 방명록을 작성합니다. 방을 방문한 사용자가 메시지를 남길 수 있으며, 최대 500자까지 작성 가능합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "방명록 작성 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 요청 (내용 누락 또는 500자 초과)"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 방"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + public ResponseEntity> createGuestbook( + @PathVariable Long roomId, + @RequestBody @Valid CreateGuestbookRequest request) { + + Long userId = currentUser.getUserId(); + GuestbookResponse guestbook = guestbookService.createGuestbook(roomId, request.getContent(), userId); + + return ResponseEntity + .status(HttpStatus.CREATED) + .body(RsData.success("방명록 작성 성공", guestbook)); + } + + /** + * 방명록 수정 (작성자만 가능) + * + * @param roomId 방 ID + * @param guestbookId 방명록 ID + * @param request 수정할 내용 + * @return 수정된 방명록 + */ + @PutMapping("/{guestbookId}") + @Operation( + summary = "방명록 수정", + description = "작성한 방명록의 내용을 수정합니다. 작성자 본인만 수정할 수 있습니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "방명록 수정 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 요청 (내용 누락 또는 500자 초과)"), + @ApiResponse(responseCode = "403", description = "권한 없음 (작성자가 아님)"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 방명록"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + public ResponseEntity> updateGuestbook( + @PathVariable Long roomId, + @PathVariable Long guestbookId, + @RequestBody @Valid UpdateGuestbookRequest request) { + + Long userId = currentUser.getUserId(); + GuestbookResponse guestbook = guestbookService.updateGuestbook(guestbookId, request.getContent(), userId); + + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success("방명록 수정 성공", guestbook)); + } + + /** + * 방명록 삭제 (작성자만 가능) + * + * @param roomId 방 ID + * @param guestbookId 방명록 ID + * @return 성공 메시지 + */ + @DeleteMapping("/{guestbookId}") + @Operation( + summary = "방명록 삭제", + description = "작성한 방명록을 삭제합니다. 작성자 본인만 삭제할 수 있으며, 삭제 시 관련된 이모지 반응과 핀도 함께 삭제됩니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "방명록 삭제 성공"), + @ApiResponse(responseCode = "403", description = "권한 없음 (작성자가 아님)"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 방명록"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + public ResponseEntity> deleteGuestbook( + @PathVariable Long roomId, + @PathVariable Long guestbookId) { + + Long userId = currentUser.getUserId(); + guestbookService.deleteGuestbook(guestbookId, userId); + + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success("방명록 삭제 성공")); + } + + /** + * 방명록 이모지 반응 추가/제거 (토글) + * - 이미 반응한 이모지면 제거 + * - 반응하지 않은 이모지면 추가 + * + * @param roomId 방 ID + * @param guestbookId 방명록 ID + * @param request 이모지 + * @return 업데이트된 방명록 (반응 포함) + */ + @PostMapping("/{guestbookId}/reaction") + @Operation( + summary = "이모지 반응 토글", + description = "방명록에 이모지 반응을 추가하거나 제거합니다. 이미 해당 이모지로 반응한 경우 제거되고, 반응하지 않은 경우 추가됩니다. 한 사용자는 같은 이모지로 중복 반응할 수 없지만, 여러 종류의 이모지로 반응할 수 있습니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "이모지 반응 토글 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 요청 (이모지 형식 오류)"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 방명록"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + public ResponseEntity> toggleReaction( + @PathVariable Long roomId, + @PathVariable Long guestbookId, + @RequestBody @Valid AddGuestbookReactionRequest request) { + + Long userId = currentUser.getUserId(); + GuestbookResponse guestbook = guestbookService.toggleReaction(guestbookId, request.getEmoji(), userId); + + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success("이모지 반응 토글 성공", guestbook)); + } + + /** + * 방명록 핀 추가/제거 (토글) + * - 이미 핀한 방명록이면 제거 + * - 핀하지 않은 방명록이면 추가 + * + * @param roomId 방 ID + * @param guestbookId 방명록 ID + * @return 업데이트된 방명록 (핀 상태 포함) + */ + @PostMapping("/{guestbookId}/pin") + @Operation( + summary = "방명록 개인 핀 토글", + description = "방명록을 개인 핀에 추가하거나 제거합니다. 핀한 방명록은 목록 조회 시 최상단에 표시됩니다. 각 사용자는 자신만의 핀 목록을 가지며, 다른 사용자에게는 영향을 주지 않습니다. (공지사항 핀과 다름)" + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "방명록 핀 토글 성공"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 방명록"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + public ResponseEntity> togglePin( + @PathVariable Long roomId, + @PathVariable Long guestbookId) { + + Long userId = currentUser.getUserId(); + GuestbookResponse guestbook = guestbookService.togglePin(guestbookId, userId); + + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success("방명록 핀 토글 성공", guestbook)); + } +} diff --git a/src/main/java/com/back/domain/studyroom/dto/AddGuestbookReactionRequest.java b/src/main/java/com/back/domain/studyroom/dto/AddGuestbookReactionRequest.java new file mode 100644 index 00000000..466881a1 --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/dto/AddGuestbookReactionRequest.java @@ -0,0 +1,18 @@ +package com.back.domain.studyroom.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class AddGuestbookReactionRequest { + + @NotBlank(message = "이모지는 필수입니다") + @Size(max = 10, message = "이모지는 10자를 초과할 수 없습니다") + private String emoji; +} diff --git a/src/main/java/com/back/domain/studyroom/dto/CreateGuestbookRequest.java b/src/main/java/com/back/domain/studyroom/dto/CreateGuestbookRequest.java new file mode 100644 index 00000000..534ed6fc --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/dto/CreateGuestbookRequest.java @@ -0,0 +1,17 @@ +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; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class CreateGuestbookRequest { + + @NotBlank(message = "방명록 내용은 필수입니다") + @Size(max = 500, message = "방명록은 500자를 초과할 수 없습니다") + private String content; +} diff --git a/src/main/java/com/back/domain/studyroom/dto/GuestbookReactionSummary.java b/src/main/java/com/back/domain/studyroom/dto/GuestbookReactionSummary.java new file mode 100644 index 00000000..7e394638 --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/dto/GuestbookReactionSummary.java @@ -0,0 +1,21 @@ +package com.back.domain.studyroom.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +/** + * 방명록 이모지 반응 요약 정보 + * 이모지별 개수와 반응한 사용자 정보 포함 + */ +@Getter +@Builder +@AllArgsConstructor +public class GuestbookReactionSummary { + private String emoji; + private Long count; + private Boolean reactedByMe; // 현재 사용자가 이 이모지로 반응했는지 + private List recentUsers; // 최근 반응한 사용자 닉네임 (최대 3명) +} diff --git a/src/main/java/com/back/domain/studyroom/dto/GuestbookResponse.java b/src/main/java/com/back/domain/studyroom/dto/GuestbookResponse.java new file mode 100644 index 00000000..1980b4d9 --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/dto/GuestbookResponse.java @@ -0,0 +1,44 @@ +package com.back.domain.studyroom.dto; + +import com.back.domain.studyroom.entity.RoomGuestbook; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Builder +@AllArgsConstructor +public class GuestbookResponse { + private Long guestbookId; + private Long userId; + private String nickname; + private String profileImageUrl; + private String content; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private Boolean isAuthor; // 현재 사용자가 작성자인지 + private Boolean isPinned; // 현재 사용자가 핀했는지 + private List reactions; // 이모지 반응 요약 + + public static GuestbookResponse from( + RoomGuestbook guestbook, + Long currentUserId, + List reactions, + boolean isPinned) { + return GuestbookResponse.builder() + .guestbookId(guestbook.getId()) + .userId(guestbook.getUser().getId()) + .nickname(guestbook.getUser().getNickname()) + .profileImageUrl(guestbook.getUser().getProfileImageUrl()) + .content(guestbook.getContent()) + .createdAt(guestbook.getCreatedAt()) + .updatedAt(guestbook.getUpdatedAt()) + .isAuthor(currentUserId != null && guestbook.isAuthor(currentUserId)) + .isPinned(isPinned) + .reactions(reactions) + .build(); + } +} diff --git a/src/main/java/com/back/domain/studyroom/dto/UpdateGuestbookRequest.java b/src/main/java/com/back/domain/studyroom/dto/UpdateGuestbookRequest.java new file mode 100644 index 00000000..0165f130 --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/dto/UpdateGuestbookRequest.java @@ -0,0 +1,17 @@ +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; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class UpdateGuestbookRequest { + + @NotBlank(message = "방명록 내용은 필수입니다") + @Size(max = 500, message = "방명록은 500자를 초과할 수 없습니다") + private String content; +} 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 72e6feb4..9e730dec 100644 --- a/src/main/java/com/back/domain/studyroom/entity/Room.java +++ b/src/main/java/com/back/domain/studyroom/entity/Room.java @@ -90,6 +90,11 @@ public String getRawThumbnailUrl() { @OneToMany(mappedBy = "room", cascade = CascadeType.ALL, orphanRemoval = true) public List studyRecords = new ArrayList<>(); + // 방명록 + @Builder.Default + @OneToMany(mappedBy = "room", cascade = CascadeType.ALL, orphanRemoval = true) + private List guestbooks = new ArrayList<>(); + /** * 방에 입장할 수 있는지 확인 @@ -195,6 +200,7 @@ public static Room create(String title, String description, boolean isPrivate, room.roomChatMessages = new ArrayList<>(); room.roomParticipantHistories = new ArrayList<>(); room.studyRecords = new ArrayList<>(); + room.guestbooks = new ArrayList<>(); return room; } diff --git a/src/main/java/com/back/domain/studyroom/entity/RoomGuestbook.java b/src/main/java/com/back/domain/studyroom/entity/RoomGuestbook.java new file mode 100644 index 00000000..74b74ad1 --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/entity/RoomGuestbook.java @@ -0,0 +1,53 @@ +package com.back.domain.studyroom.entity; + +import com.back.domain.user.common.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 +@Table(name = "room_guestbook") +public class RoomGuestbook extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "room_id", nullable = false) + private Room room; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(nullable = false, length = 500) + private String content; + + @OneToMany(mappedBy = "guestbook", cascade = CascadeType.ALL, orphanRemoval = true) + private List reactions = new ArrayList<>(); + + private RoomGuestbook(Room room, User user, String content) { + this.room = room; + this.user = user; + this.content = content; + } + + public static RoomGuestbook create(Room room, User user, String content) { + return new RoomGuestbook(room, user, content); + } + + public void updateContent(String content) { + this.content = content; + } + + public boolean isAuthor(Long userId) { + return this.user.getId().equals(userId); + } +} diff --git a/src/main/java/com/back/domain/studyroom/entity/RoomGuestbookPin.java b/src/main/java/com/back/domain/studyroom/entity/RoomGuestbookPin.java new file mode 100644 index 00000000..112e70a2 --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/entity/RoomGuestbookPin.java @@ -0,0 +1,48 @@ +package com.back.domain.studyroom.entity; + +import com.back.domain.user.common.entity.User; +import com.back.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 방명록 개인 핀 엔티티 + * 각 사용자가 자신만의 방명록 핀을 설정할 수 있음 + * 핀한 방명록은 목록 조회 시 최상단에 표시됨 + */ +@Entity +@Getter +@NoArgsConstructor +@Table( + name = "room_guestbook_pin", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_user_guestbook_pin", + columnNames = {"user_id", "guestbook_id"} + ) + } +) +public class RoomGuestbookPin extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "guestbook_id", nullable = false) + private RoomGuestbook guestbook; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + private RoomGuestbookPin(RoomGuestbook guestbook, User user) { + this.guestbook = guestbook; + this.user = user; + } + + public static RoomGuestbookPin create(RoomGuestbook guestbook, User user) { + return new RoomGuestbookPin(guestbook, user); + } + + public boolean isPinnedBy(Long userId) { + return this.user.getId().equals(userId); + } +} diff --git a/src/main/java/com/back/domain/studyroom/entity/RoomGuestbookReaction.java b/src/main/java/com/back/domain/studyroom/entity/RoomGuestbookReaction.java new file mode 100644 index 00000000..a88b7c98 --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/entity/RoomGuestbookReaction.java @@ -0,0 +1,51 @@ +package com.back.domain.studyroom.entity; + +import com.back.domain.user.common.entity.User; +import com.back.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 방명록 이모지 반응 엔티티 + * 각 방명록에 사용자들이 남기는 이모지 반응 + */ +@Entity +@Getter +@NoArgsConstructor +@Table( + name = "room_guestbook_reaction", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_guestbook_user_emoji", + columnNames = {"guestbook_id", "user_id", "emoji"} + ) + } +) +public class RoomGuestbookReaction extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "guestbook_id", nullable = false) + private RoomGuestbook guestbook; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(nullable = false, length = 10) + private String emoji; + + private RoomGuestbookReaction(RoomGuestbook guestbook, User user, String emoji) { + this.guestbook = guestbook; + this.user = user; + this.emoji = emoji; + } + + public static RoomGuestbookReaction create(RoomGuestbook guestbook, User user, String emoji) { + return new RoomGuestbookReaction(guestbook, user, emoji); + } + + public boolean isReactedBy(Long userId) { + return this.user.getId().equals(userId); + } +} diff --git a/src/main/java/com/back/domain/studyroom/repository/RoomGuestbookPinRepository.java b/src/main/java/com/back/domain/studyroom/repository/RoomGuestbookPinRepository.java new file mode 100644 index 00000000..5c04d657 --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/repository/RoomGuestbookPinRepository.java @@ -0,0 +1,49 @@ +package com.back.domain.studyroom.repository; + +import com.back.domain.studyroom.entity.RoomGuestbookPin; +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 RoomGuestbookPinRepository extends JpaRepository { + + /** + * 특정 사용자가 특정 방명록을 핀했는지 조회 + */ + Optional findByGuestbookIdAndUserId(Long guestbookId, Long userId); + + /** + * 특정 사용자가 핀한 방명록 ID 목록 조회 + */ + @Query("SELECT p.guestbook.id FROM RoomGuestbookPin p WHERE p.user.id = :userId") + Set findPinnedGuestbookIdsByUserId(@Param("userId") Long userId); + + /** + * 특정 사용자가 특정 방의 방명록들 중 핀한 것들의 ID 조회 + */ + @Query("SELECT p.guestbook.id FROM RoomGuestbookPin p " + + "WHERE p.user.id = :userId " + + "AND p.guestbook.room.id = :roomId") + Set findPinnedGuestbookIdsByUserIdAndRoomId( + @Param("userId") Long userId, + @Param("roomId") Long roomId); + + /** + * 특정 사용자가 핀한 방명록 개수 + */ + long countByUserId(Long userId); + + /** + * 특정 방명록을 핀한 사용자 수 + */ + long countByGuestbookId(Long guestbookId); + + /** + * 특정 사용자가 핀한 모든 핀 조회 + */ + List findByUserId(Long userId); +} diff --git a/src/main/java/com/back/domain/studyroom/repository/RoomGuestbookReactionRepository.java b/src/main/java/com/back/domain/studyroom/repository/RoomGuestbookReactionRepository.java new file mode 100644 index 00000000..e6644964 --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/repository/RoomGuestbookReactionRepository.java @@ -0,0 +1,41 @@ +package com.back.domain.studyroom.repository; + +import com.back.domain.studyroom.entity.RoomGuestbookReaction; +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; + +public interface RoomGuestbookReactionRepository extends JpaRepository { + + /** + * 특정 방명록의 모든 반응 조회 + */ + @Query("SELECT r FROM RoomGuestbookReaction r " + + "JOIN FETCH r.user " + + "WHERE r.guestbook.id = :guestbookId") + List findByGuestbookIdWithUser(@Param("guestbookId") Long guestbookId); + + /** + * 특정 방명록에 특정 사용자가 특정 이모지로 반응했는지 조회 + */ + Optional findByGuestbookIdAndUserIdAndEmoji( + Long guestbookId, Long userId, String emoji); + + /** + * 특정 방명록에 특정 사용자의 모든 반응 조회 + */ + List findByGuestbookIdAndUserId(Long guestbookId, Long userId); + + /** + * 특정 방명록의 특정 이모지 반응 개수 + */ + long countByGuestbookIdAndEmoji(Long guestbookId, String emoji); + + /** + * 특정 방명록의 전체 반응 개수 + */ + long countByGuestbookId(Long guestbookId); +} diff --git a/src/main/java/com/back/domain/studyroom/repository/RoomGuestbookRepository.java b/src/main/java/com/back/domain/studyroom/repository/RoomGuestbookRepository.java new file mode 100644 index 00000000..14340577 --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/repository/RoomGuestbookRepository.java @@ -0,0 +1,63 @@ +package com.back.domain.studyroom.repository; + +import com.back.domain.studyroom.entity.RoomGuestbook; +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.Optional; + +public interface RoomGuestbookRepository extends JpaRepository { + + /** + * 특정 방의 방명록 목록 조회 (페이징) + * User 정보를 fetch join으로 함께 조회하여 N+1 방지 + * + * @deprecated 핀 기능을 고려하지 않은 구버전. findByRoomIdWithUserOrderByPin 사용 권장 + */ + @Deprecated + @Query("SELECT g FROM RoomGuestbook g " + + "JOIN FETCH g.user " + + "WHERE g.room.id = :roomId " + + "ORDER BY g.createdAt DESC") + Page findByRoomIdWithUser(@Param("roomId") Long roomId, Pageable pageable); + + /** + * 특정 방의 방명록 목록 조회 (핀 우선 정렬, 페이징) + * - 사용자가 핀한 방명록이 최상단에 표시됨 + * - 핀한 방명록 내에서는 최신순 + * - 핀하지 않은 방명록도 최신순 + */ + @Query("SELECT g FROM RoomGuestbook g " + + "LEFT JOIN RoomGuestbookPin p ON p.guestbook.id = g.id AND p.user.id = :userId " + + "JOIN FETCH g.user " + + "WHERE g.room.id = :roomId " + + "ORDER BY " + + "CASE WHEN p.id IS NOT NULL THEN 0 ELSE 1 END, " + // 핀한 것 우선 + "g.createdAt DESC") + Page findByRoomIdWithUserOrderByPin( + @Param("roomId") Long roomId, + @Param("userId") Long userId, + Pageable pageable); + + /** + * 방명록 단건 조회 (User, Room 함께 조회) + */ + @Query("SELECT g FROM RoomGuestbook g " + + "JOIN FETCH g.user " + + "JOIN FETCH g.room " + + "WHERE g.id = :guestbookId") + Optional findByIdWithUserAndRoom(@Param("guestbookId") Long guestbookId); + + /** + * 특정 방의 방명록 개수 조회 + */ + long countByRoomId(Long roomId); + + /** + * 사용자가 특정 방에 작성한 방명록 존재 여부 + */ + boolean existsByRoomIdAndUserId(Long roomId, Long userId); +} diff --git a/src/main/java/com/back/domain/studyroom/service/RoomGuestbookService.java b/src/main/java/com/back/domain/studyroom/service/RoomGuestbookService.java new file mode 100644 index 00000000..d40c6be2 --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/service/RoomGuestbookService.java @@ -0,0 +1,284 @@ +package com.back.domain.studyroom.service; + +import com.back.domain.studyroom.dto.*; +import com.back.domain.studyroom.entity.Room; +import com.back.domain.studyroom.entity.RoomGuestbook; +import com.back.domain.studyroom.entity.RoomGuestbookPin; +import com.back.domain.studyroom.entity.RoomGuestbookReaction; +import com.back.domain.studyroom.repository.RoomGuestbookPinRepository; +import com.back.domain.studyroom.repository.RoomGuestbookReactionRepository; +import com.back.domain.studyroom.repository.RoomGuestbookRepository; +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.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Slf4j +public class RoomGuestbookService { + + private final RoomGuestbookRepository guestbookRepository; + private final RoomGuestbookReactionRepository reactionRepository; + private final RoomGuestbookPinRepository pinRepository; + private final RoomRepository roomRepository; + private final UserRepository userRepository; + + /** + * 방명록 생성 + */ + @Transactional + public GuestbookResponse createGuestbook(Long roomId, String content, Long userId) { + Room room = roomRepository.findById(roomId) + .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); + + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + RoomGuestbook guestbook = RoomGuestbook.create(room, user, content); + RoomGuestbook savedGuestbook = guestbookRepository.save(guestbook); + + log.info("방명록 생성 완료 - RoomId: {}, UserId: {}, GuestbookId: {}", + roomId, userId, savedGuestbook.getId()); + + return GuestbookResponse.from(savedGuestbook, userId, Collections.emptyList(), false); + } + + /** + * 방명록 목록 조회 (페이징, 이모지 반응 포함, 핀 우선 정렬) + * 로그인한 사용자가 핀한 방명록이 최상단에 표시됨 + */ + public Page getGuestbooks(Long roomId, Long currentUserId, Pageable pageable) { + // 방 존재 확인 + if (!roomRepository.existsById(roomId)) { + throw new CustomException(ErrorCode.ROOM_NOT_FOUND); + } + + // 핀을 고려한 방명록 목록 조회 (로그인 사용자만 핀 정렬 적용) + Page guestbooks; + if (currentUserId != null) { + guestbooks = guestbookRepository.findByRoomIdWithUserOrderByPin(roomId, currentUserId, pageable); + } else { + // 비로그인 사용자는 일반 정렬 + guestbooks = guestbookRepository.findByRoomIdWithUser(roomId, pageable); + } + + // 방명록 ID 목록 추출 + List guestbookIds = guestbooks.getContent().stream() + .map(RoomGuestbook::getId) + .collect(Collectors.toList()); + + // 현재 사용자가 핀한 방명록 ID Set (로그인 사용자만) + Set pinnedGuestbookIds = currentUserId != null + ? pinRepository.findPinnedGuestbookIdsByUserIdAndRoomId(currentUserId, roomId) + : Collections.emptySet(); + + // 모든 방명록의 반응 일괄 조회 (N+1 방지) + Map> reactionsMap = new HashMap<>(); + if (!guestbookIds.isEmpty()) { + for (Long guestbookId : guestbookIds) { + List reactions = reactionRepository.findByGuestbookIdWithUser(guestbookId); + reactionsMap.put(guestbookId, reactions); + } + } + + return guestbooks.map(guestbook -> { + List reactions = reactionsMap.getOrDefault(guestbook.getId(), Collections.emptyList()); + List reactionSummaries = buildReactionSummaries(reactions, currentUserId); + boolean isPinned = pinnedGuestbookIds.contains(guestbook.getId()); + return GuestbookResponse.from(guestbook, currentUserId, reactionSummaries, isPinned); + }); + } + + /** + * 방명록 단건 조회 + */ + public GuestbookResponse getGuestbook(Long guestbookId, Long currentUserId) { + RoomGuestbook guestbook = guestbookRepository.findByIdWithUserAndRoom(guestbookId) + .orElseThrow(() -> new CustomException(ErrorCode.GUESTBOOK_NOT_FOUND)); + + List reactions = reactionRepository.findByGuestbookIdWithUser(guestbookId); + List reactionSummaries = buildReactionSummaries(reactions, currentUserId); + + // 핀 여부 확인 + boolean isPinned = currentUserId != null && + pinRepository.findByGuestbookIdAndUserId(guestbookId, currentUserId).isPresent(); + + return GuestbookResponse.from(guestbook, currentUserId, reactionSummaries, isPinned); + } + + /** + * 방명록 수정 (작성자만 가능) + */ + @Transactional + public GuestbookResponse updateGuestbook(Long guestbookId, String content, Long userId) { + RoomGuestbook guestbook = guestbookRepository.findByIdWithUserAndRoom(guestbookId) + .orElseThrow(() -> new CustomException(ErrorCode.GUESTBOOK_NOT_FOUND)); + + // 작성자 권한 확인 + if (!guestbook.isAuthor(userId)) { + throw new CustomException(ErrorCode.GUESTBOOK_NO_PERMISSION); + } + + guestbook.updateContent(content); + + List reactions = reactionRepository.findByGuestbookIdWithUser(guestbookId); + List reactionSummaries = buildReactionSummaries(reactions, userId); + + // 핀 여부 확인 + boolean isPinned = pinRepository.findByGuestbookIdAndUserId(guestbookId, userId).isPresent(); + + log.info("방명록 수정 완료 - GuestbookId: {}, UserId: {}", guestbookId, userId); + + return GuestbookResponse.from(guestbook, userId, reactionSummaries, isPinned); + } + + /** + * 방명록 삭제 (작성자만 가능) + */ + @Transactional + public void deleteGuestbook(Long guestbookId, Long userId) { + RoomGuestbook guestbook = guestbookRepository.findByIdWithUserAndRoom(guestbookId) + .orElseThrow(() -> new CustomException(ErrorCode.GUESTBOOK_NOT_FOUND)); + + // 작성자 권한 확인 + if (!guestbook.isAuthor(userId)) { + throw new CustomException(ErrorCode.GUESTBOOK_NO_PERMISSION); + } + + guestbookRepository.delete(guestbook); + + log.info("방명록 삭제 완료 - GuestbookId: {}, UserId: {}", guestbookId, userId); + } + + /** + * 방명록 이모지 반응 추가/제거 토글 + * - 이미 반응한 이모지면 제거 + * - 반응하지 않은 이모지면 추가 + */ + @Transactional + public GuestbookResponse toggleReaction(Long guestbookId, String emoji, Long userId) { + RoomGuestbook guestbook = guestbookRepository.findByIdWithUserAndRoom(guestbookId) + .orElseThrow(() -> new CustomException(ErrorCode.GUESTBOOK_NOT_FOUND)); + + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + // 기존 반응 확인 + Optional existingReaction = + reactionRepository.findByGuestbookIdAndUserIdAndEmoji(guestbookId, userId, emoji); + + if (existingReaction.isPresent()) { + // 이미 반응한 경우 → 제거 + reactionRepository.delete(existingReaction.get()); + log.info("방명록 반응 제거 - GuestbookId: {}, UserId: {}, Emoji: {}", + guestbookId, userId, emoji); + } else { + // 반응하지 않은 경우 → 추가 + RoomGuestbookReaction reaction = RoomGuestbookReaction.create(guestbook, user, emoji); + reactionRepository.save(reaction); + log.info("방명록 반응 추가 - GuestbookId: {}, UserId: {}, Emoji: {}", + guestbookId, userId, emoji); + } + + // 업데이트된 반응 목록 조회 + List reactions = reactionRepository.findByGuestbookIdWithUser(guestbookId); + List reactionSummaries = buildReactionSummaries(reactions, userId); + + // 핀 여부 확인 + boolean isPinned = pinRepository.findByGuestbookIdAndUserId(guestbookId, userId).isPresent(); + + return GuestbookResponse.from(guestbook, userId, reactionSummaries, isPinned); + } + + /** + * 방명록 핀 추가/제거 토글 + * - 이미 핀한 방명록이면 제거 + * - 핀하지 않은 방명록이면 추가 + */ + @Transactional + public GuestbookResponse togglePin(Long guestbookId, Long userId) { + RoomGuestbook guestbook = guestbookRepository.findByIdWithUserAndRoom(guestbookId) + .orElseThrow(() -> new CustomException(ErrorCode.GUESTBOOK_NOT_FOUND)); + + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + // 기존 핀 확인 + Optional existingPin = + pinRepository.findByGuestbookIdAndUserId(guestbookId, userId); + + boolean isPinned; + if (existingPin.isPresent()) { + // 이미 핀한 경우 → 제거 + pinRepository.delete(existingPin.get()); + isPinned = false; + log.info("방명록 핀 제거 - GuestbookId: {}, UserId: {}", guestbookId, userId); + } else { + // 핀하지 않은 경우 → 추가 + RoomGuestbookPin pin = RoomGuestbookPin.create(guestbook, user); + pinRepository.save(pin); + isPinned = true; + log.info("방명록 핀 추가 - GuestbookId: {}, UserId: {}", guestbookId, userId); + } + + // 반응 목록 조회 + List reactions = reactionRepository.findByGuestbookIdWithUser(guestbookId); + List reactionSummaries = buildReactionSummaries(reactions, userId); + + return GuestbookResponse.from(guestbook, userId, reactionSummaries, isPinned); + } + + /** + * 이모지 반응 요약 정보 생성 + * 이모지별로 그룹화하여 개수와 반응한 사용자 정보 포함 + */ + private List buildReactionSummaries( + List reactions, Long currentUserId) { + + if (reactions.isEmpty()) { + return Collections.emptyList(); + } + + // 이모지별로 그룹화 + Map> groupedByEmoji = reactions.stream() + .collect(Collectors.groupingBy(RoomGuestbookReaction::getEmoji)); + + return groupedByEmoji.entrySet().stream() + .map(entry -> { + String emoji = entry.getKey(); + List emojiReactions = entry.getValue(); + + // 현재 사용자가 이 이모지로 반응했는지 + boolean reactedByMe = currentUserId != null && + emojiReactions.stream() + .anyMatch(r -> r.isReactedBy(currentUserId)); + + // 최근 반응한 사용자 닉네임 (최대 3명) + List recentUsers = emojiReactions.stream() + .limit(3) + .map(r -> r.getUser().getNickname()) + .collect(Collectors.toList()); + + return GuestbookReactionSummary.builder() + .emoji(emoji) + .count((long) emojiReactions.size()) + .reactedByMe(reactedByMe) + .recentUsers(recentUsers) + .build(); + }) + .sorted(Comparator.comparing(GuestbookReactionSummary::getCount).reversed()) + .collect(Collectors.toList()); + } +} 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 fb4b582e..897970ad 100644 --- a/src/main/java/com/back/domain/studyroom/service/RoomService.java +++ b/src/main/java/com/back/domain/studyroom/service/RoomService.java @@ -346,9 +346,9 @@ public void updateRoomSettings(Long roomId, String title, String description, // 썸네일 변경 처리 String thumbnailUrl = room.getRawThumbnailUrl(); // 기존 URL 유지 if (thumbnailAttachmentId != null) { - // 기존 매핑 삭제 + 새 매핑 생성 + // 기존 매핑 삭제 + 새 매핑 생성 (S3 파일도 삭제) thumbnailUrl = roomThumbnailService.updateThumbnailMapping( - roomId, thumbnailAttachmentId); + roomId, thumbnailAttachmentId, userId); } room.updateSettings(title, description, maxParticipants, thumbnailUrl); @@ -448,8 +448,8 @@ public void terminateRoom(Long roomId, Long userId) { throw new CustomException(ErrorCode.NOT_ROOM_MANAGER); } - // 썸네일 매핑 삭제 - roomThumbnailService.deleteThumbnailMapping(roomId); + // 썸네일 매핑 삭제 (S3 파일 + FileAttachment + Mapping) + roomThumbnailService.deleteThumbnailMapping(roomId, userId); room.terminate(); diff --git a/src/main/java/com/back/domain/studyroom/service/RoomThumbnailService.java b/src/main/java/com/back/domain/studyroom/service/RoomThumbnailService.java index 0319670f..5384766a 100644 --- a/src/main/java/com/back/domain/studyroom/service/RoomThumbnailService.java +++ b/src/main/java/com/back/domain/studyroom/service/RoomThumbnailService.java @@ -5,6 +5,7 @@ import com.back.domain.file.entity.FileAttachment; import com.back.domain.file.repository.AttachmentMappingRepository; import com.back.domain.file.repository.FileAttachmentRepository; +import com.back.domain.file.service.FileService; import com.back.global.exception.CustomException; import com.back.global.exception.ErrorCode; import lombok.RequiredArgsConstructor; @@ -25,6 +26,7 @@ public class RoomThumbnailService { private final FileAttachmentRepository fileAttachmentRepository; private final AttachmentMappingRepository attachmentMappingRepository; + private final FileService fileService; /** * 방 생성 시 썸네일 매핑 생성 @@ -60,42 +62,83 @@ public String createThumbnailMapping(Long roomId, Long thumbnailAttachmentId) { /** * 방 수정 시 썸네일 변경 - * 1. 기존 매핑 삭제 + * 1. 기존 매핑 및 파일 삭제 (S3 + FileAttachment + Mapping) * 2. 새 매핑 생성 * * @param roomId 방 ID * @param newThumbnailAttachmentId 새 썸네일 파일 ID (null이면 변경 없음) + * @param userId 요청자 ID (파일 삭제 권한 검증용) * @return 새 썸네일 URL (null이면 변경 없음) */ @Transactional - public String updateThumbnailMapping(Long roomId, Long newThumbnailAttachmentId) { + public String updateThumbnailMapping(Long roomId, Long newThumbnailAttachmentId, Long userId) { if (newThumbnailAttachmentId == null) { // null이면 썸네일 변경 없음 return null; } - // 기존 매핑 모두 삭제 + // 기존 매핑 및 파일 삭제 (S3 + FileAttachment 포함) + List mappings = attachmentMappingRepository + .findAllByEntityTypeAndEntityId(EntityType.STUDY_ROOM, roomId); + + for (AttachmentMapping mapping : mappings) { + FileAttachment oldAttachment = mapping.getFileAttachment(); + if (oldAttachment != null) { + // FileService를 사용하여 S3 파일 + FileAttachment 삭제 + try { + fileService.deleteFile(oldAttachment.getId(), userId); + log.info("기존 썸네일 파일 삭제 - RoomId: {}, AttachmentId: {}", + roomId, oldAttachment.getId()); + } catch (Exception e) { + log.error("썸네일 파일 삭제 실패 - RoomId: {}, AttachmentId: {}, Error: {}", + roomId, oldAttachment.getId(), e.getMessage()); + } + } + } + + // 매핑 삭제 attachmentMappingRepository.deleteAllByEntityTypeAndEntityId( EntityType.STUDY_ROOM, roomId); - log.info("기존 썸네일 매핑 삭제 - RoomId: {}", roomId); + log.info("기존 썸네일 매핑 및 파일 삭제 완료 - RoomId: {}", roomId); // 새 매핑 생성 return createThumbnailMapping(roomId, newThumbnailAttachmentId); } /** - * 방 삭제 시 썸네일 매핑 삭제 + * 방 삭제 시 썸네일 매핑 및 파일 삭제 + * S3 파일 + FileAttachment + AttachmentMapping 모두 삭제 * * @param roomId 방 ID + * @param userId 요청자 ID (파일 삭제 권한 검증용) */ @Transactional - public void deleteThumbnailMapping(Long roomId) { - // 연결된 파일 매핑 모두 삭제 + public void deleteThumbnailMapping(Long roomId, Long userId) { + // 매핑 조회 + List mappings = attachmentMappingRepository + .findAllByEntityTypeAndEntityId(EntityType.STUDY_ROOM, roomId); + + // S3 파일 + FileAttachment 삭제 + for (AttachmentMapping mapping : mappings) { + FileAttachment fileAttachment = mapping.getFileAttachment(); + if (fileAttachment != null) { + try { + fileService.deleteFile(fileAttachment.getId(), userId); + log.info("썸네일 파일 삭제 완료 - RoomId: {}, AttachmentId: {}", + roomId, fileAttachment.getId()); + } catch (Exception e) { + log.error("썸네일 파일 삭제 실패 - RoomId: {}, AttachmentId: {}, Error: {}", + roomId, fileAttachment.getId(), e.getMessage()); + } + } + } + + // AttachmentMapping 삭제 attachmentMappingRepository.deleteAllByEntityTypeAndEntityId( EntityType.STUDY_ROOM, roomId); - log.info("방 삭제 - 썸네일 매핑 삭제 완료 - RoomId: {}", roomId); + log.info("방 삭제 - 썸네일 매핑 및 파일 삭제 완료 - RoomId: {}", roomId); } /** diff --git a/src/main/java/com/back/global/exception/ErrorCode.java b/src/main/java/com/back/global/exception/ErrorCode.java index cd66b22f..97a00616 100644 --- a/src/main/java/com/back/global/exception/ErrorCode.java +++ b/src/main/java/com/back/global/exception/ErrorCode.java @@ -41,6 +41,10 @@ public enum ErrorCode { NOT_ROOM_HOST(HttpStatus.FORBIDDEN, "ROOM_018", "방장 권한이 필요합니다."), ROOM_PASSWORD_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "ROOM_019", "이미 비밀번호가 설정되어 있습니다. 비밀번호 변경 API를 사용하세요."), + // ======================== 방명록 관련 ======================== + GUESTBOOK_NOT_FOUND(HttpStatus.NOT_FOUND, "GUESTBOOK_001", "존재하지 않는 방명록입니다."), + GUESTBOOK_NO_PERMISSION(HttpStatus.FORBIDDEN, "GUESTBOOK_002", "방명록 작성자만 수정/삭제할 수 있습니다."), + // ======================== 초대 코드 관련 ======================== INVALID_INVITE_CODE(HttpStatus.NOT_FOUND, "INVITE_001", "유효하지 않은 초대 코드입니다."), INVITE_CODE_EXPIRED(HttpStatus.BAD_REQUEST, "INVITE_002", "만료된 초대 코드입니다."), diff --git a/src/test/java/com/back/domain/studyroom/controller/RoomGuestbookControllerTest.java b/src/test/java/com/back/domain/studyroom/controller/RoomGuestbookControllerTest.java new file mode 100644 index 00000000..8ba44c93 --- /dev/null +++ b/src/test/java/com/back/domain/studyroom/controller/RoomGuestbookControllerTest.java @@ -0,0 +1,178 @@ +package com.back.domain.studyroom.controller; + +import com.back.domain.studyroom.dto.GuestbookResponse; +import com.back.domain.studyroom.service.RoomGuestbookService; +import com.back.global.security.user.CurrentUser; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("RoomGuestbookController 테스트") +class RoomGuestbookControllerTest { + + @Mock + private RoomGuestbookService guestbookService; + + @Mock + private CurrentUser currentUser; + + @InjectMocks + private RoomGuestbookController guestbookController; + + private GuestbookResponse testGuestbook; + + @BeforeEach + void setUp() { + testGuestbook = GuestbookResponse.builder() + .guestbookId(1L) + .userId(1L) + .nickname("테스트유저") + .profileImageUrl("https://example.com/profile.jpg") + .content("테스트 방명록입니다") + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .isAuthor(true) + .reactions(Collections.emptyList()) + .build(); + } + + @Test + @DisplayName("방명록 목록 조회 - 성공") + void getGuestbooks_Success() { + // given + given(currentUser.getUserIdOrNull()).willReturn(1L); + + Page guestbookPage = new PageImpl<>( + Arrays.asList(testGuestbook), + PageRequest.of(0, 20), + 1 + ); + given(guestbookService.getGuestbooks(eq(1L), eq(1L), any())).willReturn(guestbookPage); + + // when + ResponseEntity response = guestbookController.getGuestbooks(1L, 0, 20); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + verify(guestbookService, times(1)).getGuestbooks(eq(1L), eq(1L), any()); + } + + @Test + @DisplayName("방명록 단건 조회 - 성공") + void getGuestbook_Success() { + // given + given(currentUser.getUserIdOrNull()).willReturn(1L); + given(guestbookService.getGuestbook(1L, 1L)).willReturn(testGuestbook); + + // when + ResponseEntity response = guestbookController.getGuestbook(1L, 1L); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + verify(guestbookService, times(1)).getGuestbook(1L, 1L); + } + + @Test + @DisplayName("방명록 생성 - 성공") + void createGuestbook_Success() { + // given + given(currentUser.getUserId()).willReturn(1L); + given(guestbookService.createGuestbook(eq(1L), anyString(), eq(1L))).willReturn(testGuestbook); + + com.back.domain.studyroom.dto.CreateGuestbookRequest request = + new com.back.domain.studyroom.dto.CreateGuestbookRequest("테스트 방명록입니다"); + + // when + ResponseEntity response = guestbookController.createGuestbook(1L, request); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + verify(guestbookService, times(1)).createGuestbook(eq(1L), anyString(), eq(1L)); + } + + @Test + @DisplayName("방명록 수정 - 성공") + void updateGuestbook_Success() { + // given + given(currentUser.getUserId()).willReturn(1L); + given(guestbookService.updateGuestbook(eq(1L), anyString(), eq(1L))).willReturn(testGuestbook); + + com.back.domain.studyroom.dto.UpdateGuestbookRequest request = + new com.back.domain.studyroom.dto.UpdateGuestbookRequest("수정된 내용"); + + // when + ResponseEntity response = guestbookController.updateGuestbook(1L, 1L, request); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + verify(guestbookService, times(1)).updateGuestbook(eq(1L), anyString(), eq(1L)); + } + + @Test + @DisplayName("방명록 삭제 - 성공") + void deleteGuestbook_Success() { + // given + given(currentUser.getUserId()).willReturn(1L); + + // when + ResponseEntity response = guestbookController.deleteGuestbook(1L, 1L); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + verify(guestbookService, times(1)).deleteGuestbook(1L, 1L); + } + + @Test + @DisplayName("이모지 반응 토글 - 성공") + void toggleReaction_Success() { + // given + given(currentUser.getUserId()).willReturn(1L); + given(guestbookService.toggleReaction(eq(1L), eq("👍"), eq(1L))).willReturn(testGuestbook); + + com.back.domain.studyroom.dto.AddGuestbookReactionRequest request = + new com.back.domain.studyroom.dto.AddGuestbookReactionRequest("👍"); + + // when + ResponseEntity response = guestbookController.toggleReaction(1L, 1L, request); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + verify(guestbookService, times(1)).toggleReaction(eq(1L), eq("👍"), eq(1L)); + } + + @Test + @DisplayName("방명록 핀 토글 - 성공") + void togglePin_Success() { + // given + given(currentUser.getUserId()).willReturn(1L); + given(guestbookService.togglePin(1L, 1L)).willReturn(testGuestbook); + + // when + ResponseEntity response = guestbookController.togglePin(1L, 1L); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + verify(guestbookService, times(1)).togglePin(1L, 1L); + } +} diff --git a/src/test/java/com/back/domain/studyroom/service/RoomGuestbookServiceTest.java b/src/test/java/com/back/domain/studyroom/service/RoomGuestbookServiceTest.java new file mode 100644 index 00000000..7f9d9095 --- /dev/null +++ b/src/test/java/com/back/domain/studyroom/service/RoomGuestbookServiceTest.java @@ -0,0 +1,350 @@ +package com.back.domain.studyroom.service; + +import com.back.domain.studyroom.dto.GuestbookReactionSummary; +import com.back.domain.studyroom.dto.GuestbookResponse; +import com.back.domain.studyroom.entity.Room; +import com.back.domain.studyroom.entity.RoomGuestbook; +import com.back.domain.studyroom.entity.RoomGuestbookPin; +import com.back.domain.studyroom.entity.RoomGuestbookReaction; +import com.back.domain.studyroom.repository.RoomGuestbookPinRepository; +import com.back.domain.studyroom.repository.RoomGuestbookReactionRepository; +import com.back.domain.studyroom.repository.RoomGuestbookRepository; +import com.back.domain.studyroom.repository.RoomRepository; +import com.back.domain.user.common.entity.User; +import com.back.domain.user.common.entity.UserProfile; +import com.back.domain.user.common.enums.Role; +import com.back.domain.user.common.enums.UserStatus; +import com.back.domain.user.common.repository.UserRepository; +import com.back.global.exception.CustomException; +import com.back.global.exception.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("RoomGuestbookService 테스트") +class RoomGuestbookServiceTest { + + @Mock + private RoomGuestbookRepository guestbookRepository; + + @Mock + private RoomGuestbookReactionRepository reactionRepository; + + @Mock + private RoomGuestbookPinRepository pinRepository; + + @Mock + private RoomRepository roomRepository; + + @Mock + private UserRepository userRepository; + + @InjectMocks + private RoomGuestbookService guestbookService; + + private User testUser; + private Room testRoom; + private RoomGuestbook testGuestbook; + + @BeforeEach + void setUp() { + // 테스트 사용자 생성 + testUser = User.builder() + .id(1L) + .username("testuser") + .email("test@test.com") + .password("password123") + .role(Role.USER) + .userStatus(UserStatus.ACTIVE) + .build(); + + UserProfile userProfile = new UserProfile(); + userProfile.setNickname("테스트유저"); + testUser.setUserProfile(userProfile); + + // 테스트 방 생성 + testRoom = Room.create( + "테스트 방", + "테스트 설명", + false, + null, + 10, + testUser, + null, + true, + null + ); + + // 테스트 방명록 생성 + testGuestbook = RoomGuestbook.create(testRoom, testUser, "테스트 방명록입니다"); + } + + @Test + @DisplayName("방명록 생성 - 성공") + void createGuestbook_Success() { + // given + given(roomRepository.findById(1L)).willReturn(Optional.of(testRoom)); + given(userRepository.findById(1L)).willReturn(Optional.of(testUser)); + given(guestbookRepository.save(any(RoomGuestbook.class))).willReturn(testGuestbook); + + // when + GuestbookResponse response = guestbookService.createGuestbook(1L, "테스트 방명록입니다", 1L); + + // then + assertThat(response).isNotNull(); + assertThat(response.getContent()).isEqualTo("테스트 방명록입니다"); + assertThat(response.getNickname()).isEqualTo("테스트유저"); + assertThat(response.getIsAuthor()).isTrue(); + assertThat(response.getIsPinned()).isFalse(); + + verify(guestbookRepository, times(1)).save(any(RoomGuestbook.class)); + } + + @Test + @DisplayName("방명록 생성 - 방 없음 실패") + void createGuestbook_RoomNotFound() { + // given + given(roomRepository.findById(999L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> guestbookService.createGuestbook(999L, "테스트", 1L)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.ROOM_NOT_FOUND); + } + + @Test + @DisplayName("방명록 목록 조회 - 성공") + void getGuestbooks_Success() { + // given + Pageable pageable = PageRequest.of(0, 20); + Page guestbookPage = new PageImpl<>(Arrays.asList(testGuestbook), pageable, 1); + + given(roomRepository.existsById(1L)).willReturn(true); + given(guestbookRepository.findByRoomIdWithUserOrderByPin(eq(1L), eq(1L), any())).willReturn(guestbookPage); + given(pinRepository.findPinnedGuestbookIdsByUserIdAndRoomId(1L, 1L)).willReturn(Collections.emptySet()); + given(reactionRepository.findByGuestbookIdWithUser(any())).willReturn(Collections.emptyList()); + + // when + Page result = guestbookService.getGuestbooks(1L, 1L, pageable); + + // then + assertThat(result).isNotNull(); + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).getContent()).isEqualTo("테스트 방명록입니다"); + } + + @Test + @DisplayName("방명록 단건 조회 - 성공") + void getGuestbook_Success() { + // given + given(guestbookRepository.findByIdWithUserAndRoom(1L)).willReturn(Optional.of(testGuestbook)); + given(reactionRepository.findByGuestbookIdWithUser(1L)).willReturn(Collections.emptyList()); + given(pinRepository.findByGuestbookIdAndUserId(1L, 1L)).willReturn(Optional.empty()); + + // when + GuestbookResponse response = guestbookService.getGuestbook(1L, 1L); + + // then + assertThat(response).isNotNull(); + assertThat(response.getContent()).isEqualTo("테스트 방명록입니다"); + assertThat(response.getIsAuthor()).isTrue(); + assertThat(response.getIsPinned()).isFalse(); + } + + @Test + @DisplayName("방명록 수정 - 성공") + void updateGuestbook_Success() { + // given + given(guestbookRepository.findByIdWithUserAndRoom(1L)).willReturn(Optional.of(testGuestbook)); + given(reactionRepository.findByGuestbookIdWithUser(1L)).willReturn(Collections.emptyList()); + + // when + GuestbookResponse response = guestbookService.updateGuestbook(1L, "수정된 내용", 1L); + + // then + assertThat(response).isNotNull(); + assertThat(testGuestbook.getContent()).isEqualTo("수정된 내용"); + } + + @Test + @DisplayName("방명록 수정 - 권한 없음 실패") + void updateGuestbook_NoPermission() { + // given + given(guestbookRepository.findByIdWithUserAndRoom(1L)).willReturn(Optional.of(testGuestbook)); + + // when & then + assertThatThrownBy(() -> guestbookService.updateGuestbook(1L, "수정", 999L)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.GUESTBOOK_NO_PERMISSION); + } + + @Test + @DisplayName("방명록 삭제 - 성공") + void deleteGuestbook_Success() { + // given + given(guestbookRepository.findByIdWithUserAndRoom(1L)).willReturn(Optional.of(testGuestbook)); + + // when + guestbookService.deleteGuestbook(1L, 1L); + + // then + verify(guestbookRepository, times(1)).delete(testGuestbook); + } + + @Test + @DisplayName("방명록 삭제 - 권한 없음 실패") + void deleteGuestbook_NoPermission() { + // given + given(guestbookRepository.findByIdWithUserAndRoom(1L)).willReturn(Optional.of(testGuestbook)); + + // when & then + assertThatThrownBy(() -> guestbookService.deleteGuestbook(1L, 999L)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.GUESTBOOK_NO_PERMISSION); + } + + @Test + @DisplayName("이모지 반응 추가 - 성공") + void toggleReaction_Add_Success() { + // given + given(guestbookRepository.findByIdWithUserAndRoom(1L)).willReturn(Optional.of(testGuestbook)); + given(userRepository.findById(1L)).willReturn(Optional.of(testUser)); + given(reactionRepository.findByGuestbookIdAndUserIdAndEmoji(1L, 1L, "👍")).willReturn(Optional.empty()); + given(reactionRepository.findByGuestbookIdWithUser(1L)).willReturn(Collections.emptyList()); + + // when + GuestbookResponse response = guestbookService.toggleReaction(1L, "👍", 1L); + + // then + assertThat(response).isNotNull(); + verify(reactionRepository, times(1)).save(any(RoomGuestbookReaction.class)); + } + + @Test + @DisplayName("이모지 반응 제거 - 성공") + void toggleReaction_Remove_Success() { + // given + RoomGuestbookReaction reaction = RoomGuestbookReaction.create(testGuestbook, testUser, "👍"); + + given(guestbookRepository.findByIdWithUserAndRoom(1L)).willReturn(Optional.of(testGuestbook)); + given(userRepository.findById(1L)).willReturn(Optional.of(testUser)); + given(reactionRepository.findByGuestbookIdAndUserIdAndEmoji(1L, 1L, "👍")) + .willReturn(Optional.of(reaction)); + given(reactionRepository.findByGuestbookIdWithUser(1L)).willReturn(Collections.emptyList()); + + // when + GuestbookResponse response = guestbookService.toggleReaction(1L, "👍", 1L); + + // then + assertThat(response).isNotNull(); + verify(reactionRepository, times(1)).delete(reaction); + } + + @Test + @DisplayName("이모지 반응 요약 - 여러 사용자 반응") + void buildReactionSummaries_MultipleUsers() { + // given + User user2 = User.builder() + .id(2L) + .username("user2") + .email("user2@test.com") + .role(Role.USER) + .userStatus(UserStatus.ACTIVE) + .build(); + + UserProfile userProfile2 = new UserProfile(); + userProfile2.setNickname("사용자2"); + user2.setUserProfile(userProfile2); + + RoomGuestbookReaction reaction1 = RoomGuestbookReaction.create(testGuestbook, testUser, "👍"); + RoomGuestbookReaction reaction2 = RoomGuestbookReaction.create(testGuestbook, user2, "👍"); + RoomGuestbookReaction reaction3 = RoomGuestbookReaction.create(testGuestbook, testUser, "❤️"); + + given(guestbookRepository.findByIdWithUserAndRoom(1L)).willReturn(Optional.of(testGuestbook)); + given(reactionRepository.findByGuestbookIdWithUser(1L)) + .willReturn(Arrays.asList(reaction1, reaction2, reaction3)); + given(pinRepository.findByGuestbookIdAndUserId(1L, 1L)).willReturn(Optional.empty()); + + // when + GuestbookResponse response = guestbookService.getGuestbook(1L, 1L); + + // then + assertThat(response.getReactions()).hasSize(2); + + // 👍 반응 확인 (count 2) + GuestbookReactionSummary thumbsUp = response.getReactions().stream() + .filter(r -> r.getEmoji().equals("👍")) + .findFirst() + .orElse(null); + + assertThat(thumbsUp).isNotNull(); + assertThat(thumbsUp.getCount()).isEqualTo(2L); + assertThat(thumbsUp.getReactedByMe()).isTrue(); + + // ❤️ 반응 확인 (count 1) + GuestbookReactionSummary heart = response.getReactions().stream() + .filter(r -> r.getEmoji().equals("❤️")) + .findFirst() + .orElse(null); + + assertThat(heart).isNotNull(); + assertThat(heart.getCount()).isEqualTo(1L); + assertThat(heart.getReactedByMe()).isTrue(); + } + + @Test + @DisplayName("방명록 핀 추가 - 성공") + void togglePin_Add_Success() { + // given + given(guestbookRepository.findByIdWithUserAndRoom(1L)).willReturn(Optional.of(testGuestbook)); + given(userRepository.findById(1L)).willReturn(Optional.of(testUser)); + given(pinRepository.findByGuestbookIdAndUserId(1L, 1L)).willReturn(Optional.empty()); + given(reactionRepository.findByGuestbookIdWithUser(1L)).willReturn(Collections.emptyList()); + + // when + GuestbookResponse response = guestbookService.togglePin(1L, 1L); + + // then + assertThat(response).isNotNull(); + assertThat(response.getIsPinned()).isTrue(); + verify(pinRepository, times(1)).save(any(RoomGuestbookPin.class)); + } + + @Test + @DisplayName("방명록 핀 제거 - 성공") + void togglePin_Remove_Success() { + // given + RoomGuestbookPin pin = RoomGuestbookPin.create(testGuestbook, testUser); + + given(guestbookRepository.findByIdWithUserAndRoom(1L)).willReturn(Optional.of(testGuestbook)); + given(userRepository.findById(1L)).willReturn(Optional.of(testUser)); + given(pinRepository.findByGuestbookIdAndUserId(1L, 1L)).willReturn(Optional.of(pin)); + given(reactionRepository.findByGuestbookIdWithUser(1L)).willReturn(Collections.emptyList()); + + // when + GuestbookResponse response = guestbookService.togglePin(1L, 1L); + + // then + assertThat(response).isNotNull(); + assertThat(response.getIsPinned()).isFalse(); + verify(pinRepository, times(1)).delete(pin); + } +} 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 6577c0b0..4b2b1075 100644 --- a/src/test/java/com/back/domain/studyroom/service/RoomServiceTest.java +++ b/src/test/java/com/back/domain/studyroom/service/RoomServiceTest.java @@ -313,7 +313,7 @@ void updateRoomSettings_Success() { assertThat(testRoom.getTitle()).isEqualTo("변경된 제목"); assertThat(testRoom.getDescription()).isEqualTo("변경된 설명"); assertThat(testRoom.getMaxParticipants()).isEqualTo(15); - verify(roomThumbnailService, never()).updateThumbnailMapping(any(), any()); + verify(roomThumbnailService, never()).updateThumbnailMapping(any(), any(), any()); // userId 파라미터 추가 } @Test @@ -348,7 +348,7 @@ void terminateRoom_Success() { // then assertThat(testRoom.getStatus()).isEqualTo(RoomStatus.TERMINATED); assertThat(testRoom.isActive()).isFalse(); - verify(roomThumbnailService, times(1)).deleteThumbnailMapping(1L); + verify(roomThumbnailService, times(1)).deleteThumbnailMapping(1L, 1L); // userId 파라미터 추가 } @Test @@ -617,7 +617,7 @@ void updateRoomSettings_WithThumbnailChange() { // given given(roomRepository.findById(1L)).willReturn(Optional.of(testRoom)); given(roomParticipantService.getParticipantCount(1L)).willReturn(0L); - given(roomThumbnailService.updateThumbnailMapping(eq(1L), eq(789L))) + given(roomThumbnailService.updateThumbnailMapping(eq(1L), eq(789L), eq(1L))) // userId 파라미터 추가 .willReturn("https://s3.amazonaws.com/bucket/new-thumbnail.jpg"); // when @@ -633,6 +633,6 @@ void updateRoomSettings_WithThumbnailChange() { // then assertThat(testRoom.getTitle()).isEqualTo("변경된 제목"); assertThat(testRoom.getThumbnailUrl()).isEqualTo("https://s3.amazonaws.com/bucket/new-thumbnail.jpg"); - verify(roomThumbnailService, times(1)).updateThumbnailMapping(eq(1L), eq(789L)); + verify(roomThumbnailService, times(1)).updateThumbnailMapping(eq(1L), eq(789L), eq(1L)); // userId 파라미터 추가 } }