diff --git a/src/main/java/com/back/domain/studyroom/controller/RoomInviteController.java b/src/main/java/com/back/domain/studyroom/controller/RoomInviteController.java new file mode 100644 index 00000000..961614b8 --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/controller/RoomInviteController.java @@ -0,0 +1,59 @@ +package com.back.domain.studyroom.controller; + +import com.back.domain.studyroom.dto.InviteCodeResponse; +import com.back.domain.studyroom.entity.RoomInviteCode; +import com.back.domain.studyroom.service.RoomInviteService; +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.*; + +/** + * 방 초대 코드 API 컨트롤러 + * - VISITOR 포함 모든 사용자가 초대 코드 발급 가능 + * - 사용자당 1개의 고유 코드 보유 + * - 3시간 유효, 만료 후 재생성 가능 + */ +@RestController +@RequestMapping("/api/rooms/{roomId}/invite") +@RequiredArgsConstructor +@Tag(name = "Room Invite API", description = "방 초대 코드 관련 API") +@SecurityRequirement(name = "Bearer Authentication") +public class RoomInviteController { + + private final RoomInviteService inviteService; + private final CurrentUser currentUser; + + @GetMapping("/me") + @Operation( + summary = "내 초대 코드 조회/생성", + description = "내 초대 코드를 조회합니다. 없으면 자동으로 생성됩니다. " + + "유효기간은 3시간이며, 만료 전까지는 같은 코드가 유지됩니다. " + + "만료된 경우 새로 생성할 수 있습니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회/생성 성공"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 방"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + public ResponseEntity> getMyInviteCode( + @Parameter(description = "방 ID", required = true) @PathVariable Long roomId) { + + Long userId = currentUser.getUserId(); + + RoomInviteCode code = inviteService.getOrCreateMyInviteCode(roomId, userId); + InviteCodeResponse response = InviteCodeResponse.from(code); + + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success("초대 코드 조회 완료", response)); + } +} diff --git a/src/main/java/com/back/domain/studyroom/controller/RoomInvitePublicController.java b/src/main/java/com/back/domain/studyroom/controller/RoomInvitePublicController.java new file mode 100644 index 00000000..27c8e743 --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/controller/RoomInvitePublicController.java @@ -0,0 +1,67 @@ +package com.back.domain.studyroom.controller; + +import com.back.domain.studyroom.dto.JoinRoomResponse; +import com.back.domain.studyroom.entity.Room; +import com.back.domain.studyroom.entity.RoomMember; +import com.back.domain.studyroom.service.RoomInviteService; +import com.back.domain.studyroom.service.RoomService; +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.*; + +/** + * 초대 코드 입장 API + * - 비로그인 시 401 반환 (프론트에서 로그인 페이지로 리다이렉트) + */ +@RestController +@RequestMapping("/api/invite") +@RequiredArgsConstructor +@Tag(name = "Room Invite API", description = "초대 코드로 방 입장 API") +public class RoomInvitePublicController { + + private final RoomInviteService inviteService; + private final RoomService roomService; + private final CurrentUser currentUser; + + @PostMapping("/{inviteCode}") + @SecurityRequirement(name = "Bearer Authentication") + @Operation( + summary = "초대 코드로 방 입장", + description = "초대 코드를 사용하여 방에 입장합니다. " + + "비밀번호가 걸린 방도 초대 코드로 입장 가능합니다. " + + "비로그인 사용자는 401 응답을 받습니다 (프론트에서 로그인 페이지로 이동)." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "입장 성공"), + @ApiResponse(responseCode = "400", description = "만료되었거나 유효하지 않은 코드"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 초대 코드"), + @ApiResponse(responseCode = "401", description = "인증 필요 (비로그인)") + }) + public ResponseEntity> joinByInviteCode( + @Parameter(description = "초대 코드", required = true, example = "A3B9C2D1") + @PathVariable String inviteCode) { + + // 로그인 체크는 Spring Security에서 자동 처리 (비로그인 시 401) + Long userId = currentUser.getUserId(); + + // 초대 코드 검증 및 방 조회 + Room room = inviteService.getRoomByInviteCode(inviteCode); + + // 방 입장 (비밀번호 무시) + RoomMember member = roomService.joinRoom(room.getId(), null, userId); + JoinRoomResponse response = JoinRoomResponse.from(member); + + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success("초대 코드로 입장 완료", response)); + } +} diff --git a/src/main/java/com/back/domain/studyroom/dto/InviteCodeResponse.java b/src/main/java/com/back/domain/studyroom/dto/InviteCodeResponse.java new file mode 100644 index 00000000..a62ad454 --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/dto/InviteCodeResponse.java @@ -0,0 +1,55 @@ +package com.back.domain.studyroom.dto; + +import com.back.domain.studyroom.entity.RoomInviteCode; +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +@AllArgsConstructor +@Schema(description = "초대 코드 응답") +public class InviteCodeResponse { + + @Schema(description = "초대 코드", example = "A3B9C2D1") + private String inviteCode; + + @Schema(description = "초대 링크", example = "https://catfe.com/invite/A3B9C2D1") + private String inviteLink; + + @Schema(description = "방 ID", example = "1") + private Long roomId; + + @Schema(description = "방 제목", example = "스터디 모임") + private String roomTitle; + + @Schema(description = "생성자 닉네임", example = "홍길동") + private String createdByNickname; + + @Schema(description = "만료 시간") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime expiresAt; + + @Schema(description = "활성 여부", example = "true") + private boolean isActive; + + @Schema(description = "유효 여부 (만료되지 않았는지)", example = "true") + private boolean isValid; + + public static InviteCodeResponse from(RoomInviteCode code) { + return InviteCodeResponse.builder() + .inviteCode(code.getInviteCode()) + .inviteLink("https://catfe.com/invite/" + code.getInviteCode()) + .roomId(code.getRoom().getId()) + .roomTitle(code.getRoom().getTitle()) + .createdByNickname(code.getCreatedBy().getNickname()) + .expiresAt(code.getExpiresAt()) + .isActive(code.isActive()) + .isValid(code.isValid()) // 만료 여부 체크 + .build(); + } +} diff --git a/src/main/java/com/back/domain/studyroom/entity/RoomInviteCode.java b/src/main/java/com/back/domain/studyroom/entity/RoomInviteCode.java new file mode 100644 index 00000000..0d8a5c6b --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/entity/RoomInviteCode.java @@ -0,0 +1,90 @@ +package com.back.domain.studyroom.entity; + +import com.back.domain.user.entity.User; +import com.back.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 방 초대 코드 엔티티 + * - 모든 참여자가 생성 가능 + * - 3시간 유효 + * - 사용 횟수 무제한 + */ +@Entity +@Table( + name = "room_invite_codes", + indexes = { + @Index(name = "idx_invite_code", columnList = "invite_code"), + @Index(name = "idx_room_id", columnList = "room_id"), + @Index(name = "idx_expires_at", columnList = "expires_at") + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RoomInviteCode extends BaseEntity { + + @Column(name = "invite_code", nullable = false, unique = true, length = 8) + private String inviteCode; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "room_id", nullable = false) + private Room room; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "created_by", nullable = false) + private User createdBy; + + @Column(name = "expires_at", nullable = false) + private LocalDateTime expiresAt; + + @Column(name = "is_active", nullable = false) + private boolean isActive = true; + + @Builder + private RoomInviteCode(String inviteCode, Room room, User createdBy, LocalDateTime expiresAt) { + this.inviteCode = inviteCode; + this.room = room; + this.createdBy = createdBy; + this.expiresAt = expiresAt; + this.isActive = true; + } + + /** + * 초대 코드 생성 + * @param inviteCode 8자리 랜덤 코드 + * @param room 방 + * @param createdBy 생성자 + */ + public static RoomInviteCode create(String inviteCode, Room room, User createdBy) { + LocalDateTime expiresAt = LocalDateTime.now().plusHours(3); + + return RoomInviteCode.builder() + .inviteCode(inviteCode) + .room(room) + .createdBy(createdBy) + .expiresAt(expiresAt) + .build(); + } + + /** + * 유효성 검증 + */ + public boolean isValid() { + if (!isActive) return false; + if (LocalDateTime.now().isAfter(expiresAt)) return false; + return true; + } + + /** + * 비활성화 + */ + public void deactivate() { + this.isActive = false; + } +} diff --git a/src/main/java/com/back/domain/studyroom/repository/RoomInviteCodeRepository.java b/src/main/java/com/back/domain/studyroom/repository/RoomInviteCodeRepository.java new file mode 100644 index 00000000..234550e7 --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/repository/RoomInviteCodeRepository.java @@ -0,0 +1,50 @@ +package com.back.domain.studyroom.repository; + +import com.back.domain.studyroom.entity.RoomInviteCode; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.Optional; + +public interface RoomInviteCodeRepository extends JpaRepository { + + /** + * 초대 코드로 조회 + */ + @Query("SELECT ric FROM RoomInviteCode ric " + + "JOIN FETCH ric.room " + + "JOIN FETCH ric.createdBy " + + "WHERE ric.inviteCode = :inviteCode") + Optional findByInviteCode(@Param("inviteCode") String inviteCode); + + /** + * 초대 코드 중복 확인 + */ + boolean existsByInviteCode(String inviteCode); + + /** + * 사용자가 특정 방에서 생성한 활성 초대 코드 조회 + */ + @Query("SELECT ric FROM RoomInviteCode ric " + + "JOIN FETCH ric.room " + + "JOIN FETCH ric.createdBy " + + "WHERE ric.room.id = :roomId " + + "AND ric.createdBy.id = :userId " + + "AND ric.isActive = true") + Optional findByRoomIdAndCreatedByIdAndIsActiveTrue( + @Param("roomId") Long roomId, + @Param("userId") Long userId); + + /** + * 만료된 초대 코드 자동 비활성화 (배치용) + */ + @Modifying + @Query("UPDATE RoomInviteCode ric " + + "SET ric.isActive = false " + + "WHERE ric.expiresAt < :now " + + "AND ric.isActive = true") + int deactivateExpiredCodes(@Param("now") LocalDateTime now); +} diff --git a/src/main/java/com/back/domain/studyroom/scheduler/InviteCodeCleanupScheduler.java b/src/main/java/com/back/domain/studyroom/scheduler/InviteCodeCleanupScheduler.java new file mode 100644 index 00000000..c81886f9 --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/scheduler/InviteCodeCleanupScheduler.java @@ -0,0 +1,39 @@ +package com.back.domain.studyroom.scheduler; + +import com.back.domain.studyroom.repository.RoomInviteCodeRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +/** + * 초대 코드 정리 스케줄러 + * - 만료된 초대 코드 자동 비활성화 + * - 매시간 실행 + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class InviteCodeCleanupScheduler { + + private final RoomInviteCodeRepository inviteCodeRepository; + + /** + * 만료된 초대 코드 정리 + * - 매시간 정각에 실행 + */ + @Scheduled(cron = "0 0 * * * *") + @Transactional + public void cleanupExpiredInviteCodes() { + LocalDateTime now = LocalDateTime.now(); + + int deactivatedCount = inviteCodeRepository.deactivateExpiredCodes(now); + + if (deactivatedCount > 0) { + log.info("만료된 초대 코드 정리 완료 - 비활성화된 코드 수: {}", deactivatedCount); + } + } +} diff --git a/src/main/java/com/back/domain/studyroom/service/RoomInviteService.java b/src/main/java/com/back/domain/studyroom/service/RoomInviteService.java new file mode 100644 index 00000000..fb3facf1 --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/service/RoomInviteService.java @@ -0,0 +1,212 @@ +package com.back.domain.studyroom.service; + +import com.back.domain.studyroom.entity.Room; +import com.back.domain.studyroom.entity.RoomInviteCode; +import com.back.domain.studyroom.repository.RoomInviteCodeRepository; +import com.back.domain.studyroom.repository.RoomRepository; +import com.back.domain.user.entity.User; +import com.back.domain.user.repository.UserRepository; +import com.back.global.exception.CustomException; +import com.back.global.exception.ErrorCode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.security.SecureRandom; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.Random; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** + * 초대 코드 서비스 + * - DB: 영속적 저장 (생성 이력, 만료 관리) + * - Redis: 빠른 검증, TTL 자동 만료 + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Slf4j +public class RoomInviteService { + + private final RoomInviteCodeRepository inviteCodeRepository; + private final RoomRepository roomRepository; + private final UserRepository userRepository; + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + private static final String CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // 혼동 문자 제외 + private static final int CODE_LENGTH = 8; + private static final Random RANDOM = new SecureRandom(); + private static final String REDIS_KEY_PREFIX = "invite:code:"; + private static final int EXPIRY_HOURS = 3; + + /** + * 내 초대 코드 조회 (없으면 생성) + * - 활성 코드가 있으면 반환 + * - 없으면 새로 생성 + * - 만료된 코드는 비활성화 표시 + */ + @Transactional + public RoomInviteCode getOrCreateMyInviteCode(Long roomId, 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)); + + // 1. 사용자의 활성 초대 코드 조회 (DB) + Optional existingCode = + inviteCodeRepository.findByRoomIdAndCreatedByIdAndIsActiveTrue(roomId, userId); + + if (existingCode.isPresent()) { + RoomInviteCode code = existingCode.get(); + + // 2. 만료 확인 + if (code.isValid()) { + log.info("기존 초대 코드 반환 - RoomId: {}, UserId: {}, Code: {}", + roomId, userId, code.getInviteCode()); + + // Redis에도 저장 (없을 수 있으므로) + saveToRedis(code); + return code; + } else { + // 만료된 코드 비활성화 + code.deactivate(); + log.info("만료된 초대 코드 비활성화 - RoomId: {}, UserId: {}, Code: {}", + roomId, userId, code.getInviteCode()); + } + } + + // 3. 새 초대 코드 생성 + String inviteCode = generateUniqueInviteCode(); + RoomInviteCode newCode = RoomInviteCode.create(inviteCode, room, user); + inviteCodeRepository.save(newCode); + + // Redis에 저장 (3시간 TTL) + saveToRedis(newCode); + + log.info("새 초대 코드 생성 - RoomId: {}, UserId: {}, Code: {}, ExpiresAt: {}", + roomId, userId, inviteCode, newCode.getExpiresAt()); + + return newCode; + } + + /** + * 초대 코드로 방 조회 및 검증 + * - Redis 우선 조회 (빠름) + * - Redis에 없으면 DB 조회 + */ + public Room getRoomByInviteCode(String inviteCode) { + + // 1. Redis에서 먼저 확인 (빠른 조회) + RoomInviteCode code = getFromRedis(inviteCode); + + // 2. Redis에 없으면 DB 조회 + if (code == null) { + code = inviteCodeRepository.findByInviteCode(inviteCode) + .orElseThrow(() -> new CustomException(ErrorCode.INVALID_INVITE_CODE)); + + // 유효한 코드라면 Redis에 다시 저장 + if (code.isValid()) { + saveToRedis(code); + } + } + + // 3. 유효성 검증 + if (!code.isValid()) { + throw new CustomException(ErrorCode.INVITE_CODE_EXPIRED); + } + + Room room = code.getRoom(); + + log.info("초대 코드 검증 완료 - Code: {}, RoomId: {}", + inviteCode, room.getId()); + + return room; + } + + /** + * 고유한 초대 코드 생성 + */ + private String generateUniqueInviteCode() { + int maxAttempts = 10; + + for (int i = 0; i < maxAttempts; i++) { + String code = RANDOM.ints(CODE_LENGTH, 0, CODE_CHARS.length()) + .mapToObj(idx -> String.valueOf(CODE_CHARS.charAt(idx))) + .collect(Collectors.joining()); + + // DB와 Redis 모두 확인 + if (!inviteCodeRepository.existsByInviteCode(code) && + !existsInRedis(code)) { + return code; + } + } + + throw new CustomException(ErrorCode.INVITE_CODE_GENERATION_FAILED); + } + + /** + * Redis에 저장 (3시간 TTL) + */ + private void saveToRedis(RoomInviteCode code) { + try { + String key = REDIS_KEY_PREFIX + code.getInviteCode(); + + // JSON으로 변환 (roomId만 저장) + String value = String.valueOf(code.getRoom().getId()); + + // 만료 시간까지의 남은 시간 계산 + long ttl = Duration.between(LocalDateTime.now(), code.getExpiresAt()).getSeconds(); + + if (ttl > 0) { + redisTemplate.opsForValue().set(key, value, ttl, TimeUnit.SECONDS); + log.debug("Redis 저장 완료 - Code: {}, RoomId: {}, TTL: {}초", + code.getInviteCode(), code.getRoom().getId(), ttl); + } + } catch (Exception e) { + // Redis 저장 실패는 무시 (DB에는 저장됨) + log.warn("Redis 저장 실패 (무시) - Code: {}", code.getInviteCode(), e); + } + } + + /** + * Redis에서 조회 + */ + private RoomInviteCode getFromRedis(String inviteCode) { + try { + String key = REDIS_KEY_PREFIX + inviteCode; + String value = redisTemplate.opsForValue().get(key); + + if (value != null) { + Long roomId = Long.parseLong(value); + + // DB에서 전체 정보 조회 + return inviteCodeRepository.findByInviteCode(inviteCode).orElse(null); + } + } catch (Exception e) { + log.warn("Redis 조회 실패 (무시) - Code: {}", inviteCode, e); + } + return null; + } + + /** + * Redis에 존재 여부 확인 + */ + private boolean existsInRedis(String inviteCode) { + try { + String key = REDIS_KEY_PREFIX + inviteCode; + return Boolean.TRUE.equals(redisTemplate.hasKey(key)); + } catch (Exception e) { + log.warn("Redis 존재 확인 실패 (무시) - Code: {}", inviteCode, e); + return false; + } + } +} diff --git a/src/main/java/com/back/global/exception/ErrorCode.java b/src/main/java/com/back/global/exception/ErrorCode.java index 051c2eef..6f8fb02f 100644 --- a/src/main/java/com/back/global/exception/ErrorCode.java +++ b/src/main/java/com/back/global/exception/ErrorCode.java @@ -40,6 +40,12 @@ public enum ErrorCode { ROOM_PASSWORD_MISMATCH(HttpStatus.BAD_REQUEST, "ROOM_017", "현재 비밀번호가 일치하지 않습니다."), NOT_ROOM_HOST(HttpStatus.FORBIDDEN, "ROOM_018", "방장 권한이 필요합니다."), + // ======================== 초대 코드 관련 ======================== + INVALID_INVITE_CODE(HttpStatus.NOT_FOUND, "INVITE_001", "유효하지 않은 초대 코드입니다."), + INVITE_CODE_EXPIRED(HttpStatus.BAD_REQUEST, "INVITE_002", "만료된 초대 코드입니다."), + INVITE_CODE_GENERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "INVITE_003", "초대 코드 생성에 실패했습니다."), + INVITE_CODE_ALREADY_ACTIVE(HttpStatus.CONFLICT, "INVITE_004", "이미 활성화된 초대 코드가 있습니다. 만료 후 재생성 가능합니다."), + // ======================== 아바타 관련 ======================== AVATAR_NOT_FOUND(HttpStatus.NOT_FOUND, "AVATAR_001", "존재하지 않는 아바타입니다."), diff --git a/src/test/java/com/back/domain/studyroom/controller/RoomInviteControllerTest.java b/src/test/java/com/back/domain/studyroom/controller/RoomInviteControllerTest.java new file mode 100644 index 00000000..e1296b07 --- /dev/null +++ b/src/test/java/com/back/domain/studyroom/controller/RoomInviteControllerTest.java @@ -0,0 +1,407 @@ +package com.back.domain.studyroom.controller; + +import com.back.domain.studyroom.dto.InviteCodeResponse; +import com.back.domain.studyroom.entity.Room; +import com.back.domain.studyroom.entity.RoomInviteCode; +import com.back.domain.studyroom.service.RoomInviteService; +import com.back.domain.user.entity.Role; +import com.back.domain.user.entity.User; +import com.back.domain.user.entity.UserProfile; +import com.back.domain.user.entity.UserStatus; +import com.back.global.common.dto.RsData; +import com.back.global.exception.CustomException; +import com.back.global.exception.ErrorCode; +import com.back.global.security.user.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.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("RoomInviteController 테스트") +class RoomInviteControllerTest { + + @Mock + private RoomInviteService inviteService; + + @Mock + private CurrentUser currentUser; + + @InjectMocks + private RoomInviteController inviteController; + + private User testUser; + private User testUser2; + private Room testRoom; + private Room privateRoom; + private RoomInviteCode testInviteCode; + private RoomInviteCode privateRoomInviteCode; + + @BeforeEach + void setUp() { + // 테스트 사용자 1 생성 + 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); + + // 테스트 사용자 2 생성 + testUser2 = User.builder() + .id(2L) + .username("testuser2") + .email("test2@test.com") + .password("password456") + .role(Role.USER) + .userStatus(UserStatus.ACTIVE) + .build(); + + UserProfile userProfile2 = new UserProfile(); + userProfile2.setNickname("테스트유저2"); + testUser2.setUserProfile(userProfile2); + + // 공개 방 생성 + testRoom = Room.create( + "테스트 방", + "테스트 설명", + false, + null, + 10, + testUser, + null, + true, + null + ); + + setRoomId(testRoom, 1L); + + // 비공개 방 생성 + privateRoom = Room.create( + "비밀 방", + "비밀 설명", + true, + "1234", + 10, + testUser, + null, + true, + null + ); + + setRoomId(privateRoom, 2L); + + // 공개 방 초대 코드 생성 + testInviteCode = RoomInviteCode.create("ABC12345", testRoom, testUser); + + // 비공개 방 초대 코드 생성 + privateRoomInviteCode = RoomInviteCode.create("PRIVATE1", privateRoom, testUser); + } + + 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 getMyInviteCode_Success() { + // given + given(currentUser.getUserId()).willReturn(1L); + given(inviteService.getOrCreateMyInviteCode(1L, 1L)).willReturn(testInviteCode); + + // when + ResponseEntity> response = inviteController.getMyInviteCode(1L); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().isSuccess()).isTrue(); + assertThat(response.getBody().getMessage()).isEqualTo("초대 코드 조회 완료"); + + InviteCodeResponse data = response.getBody().getData(); + assertThat(data.getInviteCode()).isEqualTo("ABC12345"); + assertThat(data.getRoomId()).isEqualTo(1L); + assertThat(data.isValid()).isTrue(); + + verify(currentUser, times(1)).getUserId(); + verify(inviteService, times(1)).getOrCreateMyInviteCode(1L, 1L); + } + + @Test + @DisplayName("내 초대 코드 조회 - 비공개 방도 가능") + void getMyInviteCode_Success_PrivateRoom() { + // given + given(currentUser.getUserId()).willReturn(1L); + given(inviteService.getOrCreateMyInviteCode(2L, 1L)).willReturn(privateRoomInviteCode); + + // when + ResponseEntity> response = inviteController.getMyInviteCode(2L); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().isSuccess()).isTrue(); + + InviteCodeResponse data = response.getBody().getData(); + assertThat(data.getInviteCode()).isEqualTo("PRIVATE1"); + assertThat(data.getRoomId()).isEqualTo(2L); + + verify(inviteService, times(1)).getOrCreateMyInviteCode(2L, 1L); + } + + @Test + @DisplayName("초대 링크 URL 형식 확인") + void inviteLink_Format() { + // given + given(currentUser.getUserId()).willReturn(1L); + given(inviteService.getOrCreateMyInviteCode(1L, 1L)).willReturn(testInviteCode); + + // when + ResponseEntity> response = inviteController.getMyInviteCode(1L); + + // then + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getData().getInviteLink()) + .isEqualTo("https://catfe.com/invite/ABC12345"); + } + + @Test + @DisplayName("초대 코드 응답에 필수 정보 포함 확인") + void inviteCodeResponse_ContainsRequiredInfo() { + // given + given(currentUser.getUserId()).willReturn(1L); + given(inviteService.getOrCreateMyInviteCode(1L, 1L)).willReturn(testInviteCode); + + // when + ResponseEntity> response = inviteController.getMyInviteCode(1L); + + // then + assertThat(response.getBody()).isNotNull(); + InviteCodeResponse data = response.getBody().getData(); + + assertThat(data.getInviteCode()).isNotNull(); + assertThat(data.getInviteLink()).isNotNull(); + assertThat(data.getRoomId()).isNotNull(); + assertThat(data.getRoomTitle()).isNotNull(); + assertThat(data.getCreatedByNickname()).isNotNull(); + assertThat(data.getExpiresAt()).isNotNull(); + assertThat(data.isActive()).isTrue(); + assertThat(data.isValid()).isTrue(); + } + + @Test + @DisplayName("내 초대 코드 조회 - 방 없음 실패") + void getMyInviteCode_Fail_RoomNotFound() { + // given + given(currentUser.getUserId()).willReturn(1L); + given(inviteService.getOrCreateMyInviteCode(999L, 1L)) + .willThrow(new CustomException(ErrorCode.ROOM_NOT_FOUND)); + + // when & then + assertThatThrownBy(() -> inviteController.getMyInviteCode(999L)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.ROOM_NOT_FOUND); + + verify(inviteService, times(1)).getOrCreateMyInviteCode(999L, 1L); + } + + // ====================== 초대 코드 정보 확인 테스트 ====================== + + @Test + @DisplayName("초대 코드 만료 시간 정보 포함") + void inviteCodeResponse_ExpiresAt() { + // given + given(currentUser.getUserId()).willReturn(1L); + given(inviteService.getOrCreateMyInviteCode(1L, 1L)).willReturn(testInviteCode); + + // when + ResponseEntity> response = inviteController.getMyInviteCode(1L); + + // then + assertThat(response.getBody()).isNotNull(); + InviteCodeResponse data = response.getBody().getData(); + + assertThat(data.getExpiresAt()).isNotNull(); + assertThat(data.getExpiresAt()).isAfter(LocalDateTime.now().plusHours(2)); + assertThat(data.getExpiresAt()).isBefore(LocalDateTime.now().plusHours(4)); + } + + @Test + @DisplayName("초대 코드 활성 상태 확인") + void inviteCodeResponse_IsActive() { + // given + given(currentUser.getUserId()).willReturn(1L); + given(inviteService.getOrCreateMyInviteCode(1L, 1L)).willReturn(testInviteCode); + + // when + ResponseEntity> response = inviteController.getMyInviteCode(1L); + + // then + assertThat(response.getBody()).isNotNull(); + InviteCodeResponse data = response.getBody().getData(); + + assertThat(data.isActive()).isTrue(); + assertThat(data.isValid()).isTrue(); + } + + @Test + @DisplayName("만료된 초대 코드 응답 확인") + void inviteCodeResponse_Expired() { + // given + RoomInviteCode expiredCode = RoomInviteCode.builder() + .inviteCode("EXPIRED1") + .room(testRoom) + .createdBy(testUser) + .expiresAt(LocalDateTime.now().minusHours(1)) + .build(); + + given(currentUser.getUserId()).willReturn(1L); + given(inviteService.getOrCreateMyInviteCode(1L, 1L)).willReturn(expiredCode); + + // when + ResponseEntity> response = inviteController.getMyInviteCode(1L); + + // then + assertThat(response.getBody()).isNotNull(); + InviteCodeResponse data = response.getBody().getData(); + + assertThat(data.isValid()).isFalse(); // 만료됨 + assertThat(data.getExpiresAt()).isBefore(LocalDateTime.now()); + } + + // ====================== 엣지 케이스 테스트 ====================== + + @Test + @DisplayName("다른 사용자가 같은 방의 초대 코드 조회") + void getMyInviteCode_DifferentUser_SameRoom() { + // given - User1의 코드 + given(currentUser.getUserId()).willReturn(1L); + given(inviteService.getOrCreateMyInviteCode(1L, 1L)).willReturn(testInviteCode); + + // given - User2의 코드 + RoomInviteCode user2Code = RoomInviteCode.create("XYZ98765", testRoom, testUser2); + given(inviteService.getOrCreateMyInviteCode(1L, 2L)).willReturn(user2Code); + + // when + ResponseEntity> response1 = inviteController.getMyInviteCode(1L); + + given(currentUser.getUserId()).willReturn(2L); // 사용자 변경 + ResponseEntity> response2 = inviteController.getMyInviteCode(1L); + + // then + assertThat(response1.getBody().getData().getInviteCode()).isEqualTo("ABC12345"); + assertThat(response2.getBody().getData().getInviteCode()).isEqualTo("XYZ98765"); + assertThat(response1.getBody().getData().getRoomId()) + .isEqualTo(response2.getBody().getData().getRoomId()); // 같은 방 + } + + @Test + @DisplayName("초대 코드 재조회 시 같은 코드 반환") + void getMyInviteCode_MultipleRequests_SameCode() { + // given + given(currentUser.getUserId()).willReturn(1L); + given(inviteService.getOrCreateMyInviteCode(1L, 1L)).willReturn(testInviteCode); + + // when + ResponseEntity> response1 = inviteController.getMyInviteCode(1L); + ResponseEntity> response2 = inviteController.getMyInviteCode(1L); + + // then + assertThat(response1.getBody().getData().getInviteCode()) + .isEqualTo(response2.getBody().getData().getInviteCode()); + assertThat(response1.getBody().getData().getInviteCode()).isEqualTo("ABC12345"); + + verify(inviteService, times(2)).getOrCreateMyInviteCode(1L, 1L); + } + + @Test + @DisplayName("응답 메시지 형식 확인") + void response_MessageFormat() { + // given + given(currentUser.getUserId()).willReturn(1L); + given(inviteService.getOrCreateMyInviteCode(1L, 1L)).willReturn(testInviteCode); + + // when + ResponseEntity> response = inviteController.getMyInviteCode(1L); + + // then + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().isSuccess()).isTrue(); + assertThat(response.getBody().getMessage()).isNotBlank(); + assertThat(response.getBody().getMessage()).contains("초대 코드"); + } + + @Test + @DisplayName("HTTP 상태 코드 확인") + void response_HttpStatus() { + // given + given(currentUser.getUserId()).willReturn(1L); + given(inviteService.getOrCreateMyInviteCode(1L, 1L)).willReturn(testInviteCode); + + // when + ResponseEntity> response = inviteController.getMyInviteCode(1L); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getStatusCode().value()).isEqualTo(200); + } + + @Test + @DisplayName("방 정보 포함 확인") + void inviteCodeResponse_RoomInfo() { + // given + given(currentUser.getUserId()).willReturn(1L); + given(inviteService.getOrCreateMyInviteCode(1L, 1L)).willReturn(testInviteCode); + + // when + ResponseEntity> response = inviteController.getMyInviteCode(1L); + + // then + assertThat(response.getBody()).isNotNull(); + InviteCodeResponse data = response.getBody().getData(); + + assertThat(data.getRoomId()).isEqualTo(1L); + assertThat(data.getRoomTitle()).isEqualTo("테스트 방"); + } + + @Test + @DisplayName("생성자 정보 포함 확인") + void inviteCodeResponse_CreatorInfo() { + // given + given(currentUser.getUserId()).willReturn(1L); + given(inviteService.getOrCreateMyInviteCode(1L, 1L)).willReturn(testInviteCode); + + // when + ResponseEntity> response = inviteController.getMyInviteCode(1L); + + // then + assertThat(response.getBody()).isNotNull(); + InviteCodeResponse data = response.getBody().getData(); + + assertThat(data.getCreatedByNickname()).isEqualTo("테스트유저"); + } +} diff --git a/src/test/java/com/back/domain/studyroom/controller/RoomInvitePublicControllerTest.java b/src/test/java/com/back/domain/studyroom/controller/RoomInvitePublicControllerTest.java new file mode 100644 index 00000000..e71c0cce --- /dev/null +++ b/src/test/java/com/back/domain/studyroom/controller/RoomInvitePublicControllerTest.java @@ -0,0 +1,487 @@ +package com.back.domain.studyroom.controller; + +import com.back.domain.studyroom.dto.JoinRoomResponse; +import com.back.domain.studyroom.entity.Room; +import com.back.domain.studyroom.entity.RoomInviteCode; +import com.back.domain.studyroom.entity.RoomMember; +import com.back.domain.studyroom.entity.RoomRole; +import com.back.domain.studyroom.service.RoomInviteService; +import com.back.domain.studyroom.service.RoomService; +import com.back.domain.user.entity.Role; +import com.back.domain.user.entity.User; +import com.back.domain.user.entity.UserProfile; +import com.back.domain.user.entity.UserStatus; +import com.back.global.common.dto.RsData; +import com.back.global.exception.CustomException; +import com.back.global.exception.ErrorCode; +import com.back.global.security.user.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.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("RoomInvitePublicController 테스트") +class RoomInvitePublicControllerTest { + + @Mock + private RoomInviteService inviteService; + + @Mock + private RoomService roomService; + + @Mock + private CurrentUser currentUser; + + @InjectMocks + private RoomInvitePublicController invitePublicController; + + private User testUser; + private User testUser2; + private Room testRoom; + private Room privateRoom; + private RoomInviteCode testInviteCode; + private RoomInviteCode privateRoomInviteCode; + private RoomMember testMember; + private RoomMember privateMember; + + @BeforeEach + void setUp() { + // 테스트 사용자 1 생성 + 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); + + // 테스트 사용자 2 생성 + testUser2 = User.builder() + .id(2L) + .username("testuser2") + .email("test2@test.com") + .password("password456") + .role(Role.USER) + .userStatus(UserStatus.ACTIVE) + .build(); + + UserProfile userProfile2 = new UserProfile(); + userProfile2.setNickname("테스트유저2"); + testUser2.setUserProfile(userProfile2); + + // 공개 방 생성 + testRoom = Room.create( + "테스트 방", + "테스트 설명", + false, + null, + 10, + testUser, + null, + true, + null + ); + + setRoomId(testRoom, 1L); + + // 비공개 방 생성 + privateRoom = Room.create( + "비밀 방", + "비밀 설명", + true, + "1234", + 10, + testUser, + null, + true, + null + ); + + setRoomId(privateRoom, 2L); + + // 공개 방 초대 코드 생성 + testInviteCode = RoomInviteCode.create("ABC12345", testRoom, testUser); + + // 비공개 방 초대 코드 생성 + privateRoomInviteCode = RoomInviteCode.create("PRIVATE1", privateRoom, testUser); + + // 테스트 멤버 생성 + testMember = RoomMember.createVisitor(testRoom, testUser); + privateMember = RoomMember.createVisitor(privateRoom, testUser); + } + + 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 joinByInviteCode_Success_PublicRoom() { + // given + given(currentUser.getUserId()).willReturn(2L); + given(inviteService.getRoomByInviteCode("ABC12345")).willReturn(testRoom); + given(roomService.joinRoom(eq(1L), isNull(), eq(2L))).willReturn(testMember); + + // when + ResponseEntity> response = + invitePublicController.joinByInviteCode("ABC12345"); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().isSuccess()).isTrue(); + assertThat(response.getBody().getMessage()).isEqualTo("초대 코드로 입장 완료"); + + verify(currentUser, times(1)).getUserId(); + verify(inviteService, times(1)).getRoomByInviteCode("ABC12345"); + verify(roomService, times(1)).joinRoom(eq(1L), isNull(), eq(2L)); + } + + @Test + @DisplayName("초대 코드로 입장 - 성공 (비공개 방, 비밀번호 무시)") + void joinByInviteCode_Success_PrivateRoom_PasswordIgnored() { + // given + given(currentUser.getUserId()).willReturn(2L); + given(inviteService.getRoomByInviteCode("PRIVATE1")).willReturn(privateRoom); + given(roomService.joinRoom(eq(2L), isNull(), eq(2L))).willReturn(privateMember); + + // when + ResponseEntity> response = + invitePublicController.joinByInviteCode("PRIVATE1"); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().isSuccess()).isTrue(); + + // 비밀번호 null로 전달되는지 확인 (비밀번호 무시) + verify(roomService, times(1)).joinRoom(eq(2L), isNull(), eq(2L)); + } + + @Test + @DisplayName("초대 코드로 입장 - 응답에 방 정보 포함") + void joinByInviteCode_ResponseContainsRoomInfo() { + // given + given(currentUser.getUserId()).willReturn(2L); + given(inviteService.getRoomByInviteCode("ABC12345")).willReturn(testRoom); + given(roomService.joinRoom(eq(1L), isNull(), eq(2L))).willReturn(testMember); + + // when + ResponseEntity> response = + invitePublicController.joinByInviteCode("ABC12345"); + + // then + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getData()).isNotNull(); + + JoinRoomResponse data = response.getBody().getData(); + assertThat(data.getRoomId()).isEqualTo(1L); + assertThat(data.getUserId()).isEqualTo(1L); + } + + @Test + @DisplayName("초대 코드로 입장 - 응답에 사용자 정보 포함") + void joinByInviteCode_ResponseContainsUserInfo() { + // given + given(currentUser.getUserId()).willReturn(2L); + given(inviteService.getRoomByInviteCode("ABC12345")).willReturn(testRoom); + given(roomService.joinRoom(eq(1L), isNull(), eq(2L))).willReturn(testMember); + + // when + ResponseEntity> response = + invitePublicController.joinByInviteCode("ABC12345"); + + // then + assertThat(response.getBody()).isNotNull(); + JoinRoomResponse data = response.getBody().getData(); + + assertThat(data.getUserId()).isEqualTo(1L); + assertThat(data.getRole()).isEqualTo(RoomRole.VISITOR); + } + + @Test + @DisplayName("초대 코드로 입장 - 잘못된 코드 실패") + void joinByInviteCode_Fail_InvalidCode() { + // given + given(currentUser.getUserId()).willReturn(2L); + given(inviteService.getRoomByInviteCode("INVALID1")) + .willThrow(new CustomException(ErrorCode.INVALID_INVITE_CODE)); + + // when & then + assertThatThrownBy(() -> invitePublicController.joinByInviteCode("INVALID1")) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.INVALID_INVITE_CODE); + + verify(inviteService, times(1)).getRoomByInviteCode("INVALID1"); + verify(roomService, never()).joinRoom(anyLong(), any(), anyLong()); + } + + @Test + @DisplayName("초대 코드로 입장 - 만료된 코드 실패") + void joinByInviteCode_Fail_ExpiredCode() { + // given + given(currentUser.getUserId()).willReturn(2L); + given(inviteService.getRoomByInviteCode("EXPIRED1")) + .willThrow(new CustomException(ErrorCode.INVITE_CODE_EXPIRED)); + + // when & then + assertThatThrownBy(() -> invitePublicController.joinByInviteCode("EXPIRED1")) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.INVITE_CODE_EXPIRED); + + verify(inviteService, times(1)).getRoomByInviteCode("EXPIRED1"); + verify(roomService, never()).joinRoom(anyLong(), any(), anyLong()); + } + + // ====================== 비밀번호 처리 테스트 ====================== + + @Test + @DisplayName("초대 코드 입장 시 비밀번호 파라미터가 항상 null") + void joinByInviteCode_PasswordAlwaysNull() { + // given + given(currentUser.getUserId()).willReturn(2L); + given(inviteService.getRoomByInviteCode("ABC12345")).willReturn(testRoom); + given(roomService.joinRoom(eq(1L), isNull(), eq(2L))).willReturn(testMember); + + // when + invitePublicController.joinByInviteCode("ABC12345"); + + // then + // 비밀번호가 항상 null로 전달되는지 확인 + verify(roomService, times(1)).joinRoom(eq(1L), isNull(), eq(2L)); + } + + @Test + @DisplayName("비밀번호가 설정된 방도 초대 코드로 입장 가능") + void joinByInviteCode_PrivateRoomAccessible() { + // given + given(currentUser.getUserId()).willReturn(2L); + given(inviteService.getRoomByInviteCode("PRIVATE1")).willReturn(privateRoom); + given(roomService.joinRoom(eq(2L), isNull(), eq(2L))).willReturn(privateMember); + + // when + ResponseEntity> response = + invitePublicController.joinByInviteCode("PRIVATE1"); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().isSuccess()).isTrue(); + + // 비공개 방인데도 비밀번호 없이 입장 성공 + verify(roomService, times(1)).joinRoom(eq(2L), isNull(), eq(2L)); + } + + // ====================== HTTP 응답 테스트 ====================== + + @Test + @DisplayName("HTTP 상태 코드 확인") + void joinByInviteCode_HttpStatus() { + // given + given(currentUser.getUserId()).willReturn(2L); + given(inviteService.getRoomByInviteCode("ABC12345")).willReturn(testRoom); + given(roomService.joinRoom(eq(1L), isNull(), eq(2L))).willReturn(testMember); + + // when + ResponseEntity> response = + invitePublicController.joinByInviteCode("ABC12345"); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getStatusCode().value()).isEqualTo(200); + } + + @Test + @DisplayName("응답 메시지 형식 확인") + void joinByInviteCode_ResponseMessage() { + // given + given(currentUser.getUserId()).willReturn(2L); + given(inviteService.getRoomByInviteCode("ABC12345")).willReturn(testRoom); + given(roomService.joinRoom(eq(1L), isNull(), eq(2L))).willReturn(testMember); + + // when + ResponseEntity> response = + invitePublicController.joinByInviteCode("ABC12345"); + + // then + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().isSuccess()).isTrue(); + assertThat(response.getBody().getMessage()).isNotBlank(); + assertThat(response.getBody().getMessage()).contains("초대 코드"); + } + + // ====================== 엣지 케이스 테스트 ====================== + + @Test + @DisplayName("같은 사용자가 같은 초대 코드로 여러 번 입장 시도") + void joinByInviteCode_MultipleAttempts_SameUser() { + // given + given(currentUser.getUserId()).willReturn(2L); + given(inviteService.getRoomByInviteCode("ABC12345")).willReturn(testRoom); + given(roomService.joinRoom(eq(1L), isNull(), eq(2L))).willReturn(testMember); + + // when + ResponseEntity> response1 = + invitePublicController.joinByInviteCode("ABC12345"); + ResponseEntity> response2 = + invitePublicController.joinByInviteCode("ABC12345"); + + // then + assertThat(response1.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response2.getStatusCode()).isEqualTo(HttpStatus.OK); + + verify(inviteService, times(2)).getRoomByInviteCode("ABC12345"); + verify(roomService, times(2)).joinRoom(eq(1L), isNull(), eq(2L)); + } + + @Test + @DisplayName("다른 사용자가 같은 초대 코드로 입장") + void joinByInviteCode_DifferentUsers_SameCode() { + // given - User2 + given(currentUser.getUserId()).willReturn(2L); + given(inviteService.getRoomByInviteCode("ABC12345")).willReturn(testRoom); + + RoomMember member2 = RoomMember.createVisitor(testRoom, testUser2); + given(roomService.joinRoom(eq(1L), isNull(), eq(2L))).willReturn(member2); + + // when - User2 입장 + ResponseEntity> response1 = + invitePublicController.joinByInviteCode("ABC12345"); + + // given - User1 (코드 생성자도 입장 가능) + given(currentUser.getUserId()).willReturn(1L); + given(roomService.joinRoom(eq(1L), isNull(), eq(1L))).willReturn(testMember); + + // when - User1 입장 + ResponseEntity> response2 = + invitePublicController.joinByInviteCode("ABC12345"); + + // then + assertThat(response1.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response2.getStatusCode()).isEqualTo(HttpStatus.OK); + + verify(inviteService, times(2)).getRoomByInviteCode("ABC12345"); + verify(roomService, times(1)).joinRoom(eq(1L), isNull(), eq(2L)); + verify(roomService, times(1)).joinRoom(eq(1L), isNull(), eq(1L)); + } + + @Test + @DisplayName("초대 코드 입장 시 방 최대 인원 초과") + void joinByInviteCode_Fail_RoomFull() { + // given + given(currentUser.getUserId()).willReturn(2L); + given(inviteService.getRoomByInviteCode("ABC12345")).willReturn(testRoom); + given(roomService.joinRoom(eq(1L), isNull(), eq(2L))) + .willThrow(new CustomException(ErrorCode.ROOM_FULL)); + + // when & then + assertThatThrownBy(() -> invitePublicController.joinByInviteCode("ABC12345")) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.ROOM_FULL); + + verify(inviteService, times(1)).getRoomByInviteCode("ABC12345"); + verify(roomService, times(1)).joinRoom(eq(1L), isNull(), eq(2L)); + } + + @Test + @DisplayName("초대 코드 입장 시 이미 참여 중인 방") + void joinByInviteCode_Fail_AlreadyJoined() { + // given + given(currentUser.getUserId()).willReturn(2L); + given(inviteService.getRoomByInviteCode("ABC12345")).willReturn(testRoom); + given(roomService.joinRoom(eq(1L), isNull(), eq(2L))) + .willThrow(new CustomException(ErrorCode.ALREADY_JOINED_ROOM)); + + // when & then + assertThatThrownBy(() -> invitePublicController.joinByInviteCode("ABC12345")) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.ALREADY_JOINED_ROOM); + + verify(inviteService, times(1)).getRoomByInviteCode("ABC12345"); + verify(roomService, times(1)).joinRoom(eq(1L), isNull(), eq(2L)); + } + + @Test + @DisplayName("응답 데이터 완전성 검증") + void joinByInviteCode_ResponseDataCompleteness() { + // given + given(currentUser.getUserId()).willReturn(2L); + given(inviteService.getRoomByInviteCode("ABC12345")).willReturn(testRoom); + given(roomService.joinRoom(eq(1L), isNull(), eq(2L))).willReturn(testMember); + + // when + ResponseEntity> response = + invitePublicController.joinByInviteCode("ABC12345"); + + // then + assertThat(response.getBody()).isNotNull(); + JoinRoomResponse data = response.getBody().getData(); + + // JoinRoomResponse 실제 필드에 맞춰 검증 + assertThat(data.getRoomId()).isNotNull(); + assertThat(data.getUserId()).isNotNull(); + assertThat(data.getRole()).isNotNull(); + assertThat(data.getJoinedAt()).isNotNull(); + } + + @Test + @DisplayName("초대 코드 대소문자 구분 확인") + void joinByInviteCode_CaseSensitive() { + // given + given(currentUser.getUserId()).willReturn(2L); + given(inviteService.getRoomByInviteCode("ABC12345")).willReturn(testRoom); + given(roomService.joinRoom(eq(1L), isNull(), eq(2L))).willReturn(testMember); + + // when + ResponseEntity> response = + invitePublicController.joinByInviteCode("ABC12345"); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + // 정확한 코드가 서비스로 전달되는지 확인 + verify(inviteService, times(1)).getRoomByInviteCode("ABC12345"); + } + + @Test + @DisplayName("VISITOR 권한으로 입장 확인") + void joinByInviteCode_RoleIsVisitor() { + // given + given(currentUser.getUserId()).willReturn(2L); + given(inviteService.getRoomByInviteCode("ABC12345")).willReturn(testRoom); + given(roomService.joinRoom(eq(1L), isNull(), eq(2L))).willReturn(testMember); + + // when + ResponseEntity> response = + invitePublicController.joinByInviteCode("ABC12345"); + + // then + assertThat(response.getBody()).isNotNull(); + JoinRoomResponse data = response.getBody().getData(); + + // 초대 코드로 입장하면 VISITOR 권한 + assertThat(data.getRole()).isEqualTo(RoomRole.VISITOR); + } +} diff --git a/src/test/java/com/back/domain/studyroom/service/RoomInviteServiceTest.java b/src/test/java/com/back/domain/studyroom/service/RoomInviteServiceTest.java new file mode 100644 index 00000000..2004b100 --- /dev/null +++ b/src/test/java/com/back/domain/studyroom/service/RoomInviteServiceTest.java @@ -0,0 +1,558 @@ +package com.back.domain.studyroom.service; + +import com.back.domain.studyroom.entity.Room; +import com.back.domain.studyroom.entity.RoomInviteCode; +import com.back.domain.studyroom.repository.RoomInviteCodeRepository; +import com.back.domain.studyroom.repository.RoomRepository; +import com.back.domain.user.entity.Role; +import com.back.domain.user.entity.User; +import com.back.domain.user.entity.UserProfile; +import com.back.domain.user.entity.UserStatus; +import com.back.domain.user.repository.UserRepository; +import com.back.global.exception.CustomException; +import com.back.global.exception.ErrorCode; +import com.fasterxml.jackson.databind.ObjectMapper; +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.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("RoomInviteService 테스트") +class RoomInviteServiceTest { + + @Mock + private RoomInviteCodeRepository inviteCodeRepository; + + @Mock + private RoomRepository roomRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private ValueOperations valueOperations; + + @Mock + private ObjectMapper objectMapper; + + @InjectMocks + private RoomInviteService inviteService; + + private User testUser; + private User testUser2; + private Room testRoom; + private Room privateRoom; + private RoomInviteCode testInviteCode; + + @BeforeEach + void setUp() { + // 테스트 사용자 1 생성 + 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); + + // 테스트 사용자 2 생성 + testUser2 = User.builder() + .id(2L) + .username("testuser2") + .email("test2@test.com") + .password("password456") + .role(Role.USER) + .userStatus(UserStatus.ACTIVE) + .build(); + + UserProfile userProfile2 = new UserProfile(); + userProfile2.setNickname("테스트유저2"); + testUser2.setUserProfile(userProfile2); + + // 공개 방 생성 + testRoom = Room.create( + "테스트 방", + "테스트 설명", + false, + null, + 10, + testUser, + null, + true, + null + ); + + // Room에 ID 설정 (리플렉션) + setRoomId(testRoom, 1L); + + // 비공개 방 생성 + privateRoom = Room.create( + "비밀 방", + "비밀 설명", + true, + "1234", + 10, + testUser, + null, + true, + null + ); + + setRoomId(privateRoom, 2L); + + // 테스트 초대 코드 생성 + testInviteCode = RoomInviteCode.create("ABC12345", testRoom, testUser); + } + + 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 createInviteCode_Success_New() { + // given + given(redisTemplate.opsForValue()).willReturn(valueOperations); + given(roomRepository.findById(1L)).willReturn(Optional.of(testRoom)); + given(userRepository.findById(1L)).willReturn(Optional.of(testUser)); + given(inviteCodeRepository.findByRoomIdAndCreatedByIdAndIsActiveTrue(1L, 1L)) + .willReturn(Optional.empty()); + given(inviteCodeRepository.existsByInviteCode(anyString())).willReturn(false); + given(inviteCodeRepository.save(any(RoomInviteCode.class))).willAnswer(i -> i.getArgument(0)); + + // when + RoomInviteCode result = inviteService.getOrCreateMyInviteCode(1L, 1L); + + // then + assertThat(result).isNotNull(); + assertThat(result.getInviteCode()).hasSize(8); + assertThat(result.getRoom()).isEqualTo(testRoom); + assertThat(result.getCreatedBy()).isEqualTo(testUser); + assertThat(result.isValid()).isTrue(); + assertThat(result.isActive()).isTrue(); + + verify(inviteCodeRepository, times(1)).save(any(RoomInviteCode.class)); + } + + @Test + @DisplayName("초대 코드 조회 - 기존 활성 코드 반환") + void getInviteCode_Success_Existing() { + // given + given(redisTemplate.opsForValue()).willReturn(valueOperations); + given(roomRepository.findById(1L)).willReturn(Optional.of(testRoom)); + given(userRepository.findById(1L)).willReturn(Optional.of(testUser)); + given(inviteCodeRepository.findByRoomIdAndCreatedByIdAndIsActiveTrue(1L, 1L)) + .willReturn(Optional.of(testInviteCode)); + + // when + RoomInviteCode result = inviteService.getOrCreateMyInviteCode(1L, 1L); + + // then + assertThat(result).isEqualTo(testInviteCode); + assertThat(result.getInviteCode()).isEqualTo("ABC12345"); + assertThat(result.isValid()).isTrue(); + + verify(inviteCodeRepository, never()).save(any(RoomInviteCode.class)); + } + + @Test + @DisplayName("초대 코드 생성 - 만료된 코드 있으면 새로 생성") + void createInviteCode_Success_Expired() { + // given + given(redisTemplate.opsForValue()).willReturn(valueOperations); + // 만료된 초대 코드 생성 + RoomInviteCode expiredCode = RoomInviteCode.builder() + .inviteCode("EXPIRED1") + .room(testRoom) + .createdBy(testUser) + .expiresAt(LocalDateTime.now().minusHours(1)) // 1시간 전 만료 + .build(); + + given(roomRepository.findById(1L)).willReturn(Optional.of(testRoom)); + given(userRepository.findById(1L)).willReturn(Optional.of(testUser)); + given(inviteCodeRepository.findByRoomIdAndCreatedByIdAndIsActiveTrue(1L, 1L)) + .willReturn(Optional.of(expiredCode)); + given(inviteCodeRepository.existsByInviteCode(anyString())).willReturn(false); + given(inviteCodeRepository.save(any(RoomInviteCode.class))).willAnswer(i -> i.getArgument(0)); + + // when + RoomInviteCode result = inviteService.getOrCreateMyInviteCode(1L, 1L); + + // then + assertThat(result).isNotNull(); + assertThat(result.getInviteCode()).isNotEqualTo("EXPIRED1"); + assertThat(result.isValid()).isTrue(); + assertThat(expiredCode.isActive()).isFalse(); // 기존 코드 비활성화 + + verify(inviteCodeRepository, times(1)).save(any(RoomInviteCode.class)); + } + + @Test + @DisplayName("초대 코드 생성 - 비공개 방도 생성 가능") + void createInviteCode_Success_PrivateRoom() { + // given + given(redisTemplate.opsForValue()).willReturn(valueOperations); + given(roomRepository.findById(2L)).willReturn(Optional.of(privateRoom)); + given(userRepository.findById(1L)).willReturn(Optional.of(testUser)); + given(inviteCodeRepository.findByRoomIdAndCreatedByIdAndIsActiveTrue(2L, 1L)) + .willReturn(Optional.empty()); + given(inviteCodeRepository.existsByInviteCode(anyString())).willReturn(false); + given(inviteCodeRepository.save(any(RoomInviteCode.class))).willAnswer(i -> i.getArgument(0)); + + // when + RoomInviteCode result = inviteService.getOrCreateMyInviteCode(2L, 1L); + + // then + assertThat(result).isNotNull(); + assertThat(result.getRoom()).isEqualTo(privateRoom); + assertThat(result.isValid()).isTrue(); + + verify(inviteCodeRepository, times(1)).save(any(RoomInviteCode.class)); + } + + @Test + @DisplayName("초대 코드 생성 - 방 없음 실패") + void createInviteCode_Fail_RoomNotFound() { + // given + given(roomRepository.findById(999L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> inviteService.getOrCreateMyInviteCode(999L, 1L)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.ROOM_NOT_FOUND); + + verify(roomRepository, times(1)).findById(999L); + } + + @Test + @DisplayName("초대 코드 생성 - 사용자 없음 실패") + void createInviteCode_Fail_UserNotFound() { + // given + given(roomRepository.findById(1L)).willReturn(Optional.of(testRoom)); + given(userRepository.findById(999L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> inviteService.getOrCreateMyInviteCode(1L, 999L)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.USER_NOT_FOUND); + + verify(roomRepository, times(1)).findById(1L); + verify(userRepository, times(1)).findById(999L); + } + + // ====================== 초대 코드로 방 조회 테스트 ====================== + + @Test + @DisplayName("초대 코드로 방 조회 - 성공 (Redis 없음 → DB 조회)") + void getRoomByInviteCode_Success_FromDB() { + // given + given(redisTemplate.opsForValue()).willReturn(valueOperations); + given(valueOperations.get(anyString())).willReturn(null); // Redis에 없음 + given(inviteCodeRepository.findByInviteCode("ABC12345")) + .willReturn(Optional.of(testInviteCode)); + + // when + Room result = inviteService.getRoomByInviteCode("ABC12345"); + + // then + assertThat(result).isEqualTo(testRoom); + assertThat(result.getId()).isEqualTo(1L); + + verify(inviteCodeRepository, times(1)).findByInviteCode("ABC12345"); + } + + @Test + @DisplayName("초대 코드로 방 조회 - 성공 (Redis 있음)") + void getRoomByInviteCode_Success_FromRedis() { + // given + given(redisTemplate.opsForValue()).willReturn(valueOperations); + given(valueOperations.get("invite:code:ABC12345")).willReturn("1"); // Redis에 있음 + given(inviteCodeRepository.findByInviteCode("ABC12345")) + .willReturn(Optional.of(testInviteCode)); + + // when + Room result = inviteService.getRoomByInviteCode("ABC12345"); + + // then + assertThat(result).isEqualTo(testRoom); + assertThat(result.getId()).isEqualTo(1L); + + verify(inviteCodeRepository, times(1)).findByInviteCode("ABC12345"); + } + + @Test + @DisplayName("초대 코드로 방 조회 - 잘못된 코드") + void getRoomByInviteCode_Fail_InvalidCode() { + // given + given(redisTemplate.opsForValue()).willReturn(valueOperations); + given(valueOperations.get(anyString())).willReturn(null); + given(inviteCodeRepository.findByInviteCode("INVALID1")) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> inviteService.getRoomByInviteCode("INVALID1")) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.INVALID_INVITE_CODE); + + verify(inviteCodeRepository, times(1)).findByInviteCode("INVALID1"); + } + + @Test + @DisplayName("초대 코드로 방 조회 - 만료된 코드") + void getRoomByInviteCode_Fail_Expired() { + // given + given(redisTemplate.opsForValue()).willReturn(valueOperations); + RoomInviteCode expiredCode = RoomInviteCode.builder() + .inviteCode("EXPIRED1") + .room(testRoom) + .createdBy(testUser) + .expiresAt(LocalDateTime.now().minusHours(1)) + .build(); + + given(valueOperations.get(anyString())).willReturn(null); + given(inviteCodeRepository.findByInviteCode("EXPIRED1")) + .willReturn(Optional.of(expiredCode)); + + // when & then + assertThatThrownBy(() -> inviteService.getRoomByInviteCode("EXPIRED1")) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.INVITE_CODE_EXPIRED); + + verify(inviteCodeRepository, times(1)).findByInviteCode("EXPIRED1"); + } + + @Test + @DisplayName("초대 코드로 방 조회 - 비활성화된 코드") + void getRoomByInviteCode_Fail_Inactive() { + // given + given(redisTemplate.opsForValue()).willReturn(valueOperations); + RoomInviteCode inactiveCode = RoomInviteCode.create("INACTIVE1", testRoom, testUser); + inactiveCode.deactivate(); + + given(valueOperations.get(anyString())).willReturn(null); + given(inviteCodeRepository.findByInviteCode("INACTIVE1")) + .willReturn(Optional.of(inactiveCode)); + + // when & then + assertThatThrownBy(() -> inviteService.getRoomByInviteCode("INACTIVE1")) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.INVITE_CODE_EXPIRED); + + verify(inviteCodeRepository, times(1)).findByInviteCode("INACTIVE1"); + } + + // ====================== 초대 코드 유효성 검증 테스트 ====================== + + @Test + @DisplayName("초대 코드 유효성 검증 - 유효함") + void inviteCode_IsValid_True() { + // given + RoomInviteCode validCode = RoomInviteCode.create("VALID123", testRoom, testUser); + + // when + boolean isValid = validCode.isValid(); + + // then + assertThat(isValid).isTrue(); + } + + @Test + @DisplayName("초대 코드 유효성 검증 - 만료됨") + void inviteCode_IsValid_False_Expired() { + // given + RoomInviteCode expiredCode = RoomInviteCode.builder() + .inviteCode("EXPIRED1") + .room(testRoom) + .createdBy(testUser) + .expiresAt(LocalDateTime.now().minusHours(1)) + .build(); + + // when + boolean isValid = expiredCode.isValid(); + + // then + assertThat(isValid).isFalse(); + } + + @Test + @DisplayName("초대 코드 유효성 검증 - 비활성화됨") + void inviteCode_IsValid_False_Inactive() { + // given + RoomInviteCode inactiveCode = RoomInviteCode.create("INACTIVE1", testRoom, testUser); + inactiveCode.deactivate(); + + // when + boolean isValid = inactiveCode.isValid(); + + // then + assertThat(isValid).isFalse(); + assertThat(inactiveCode.isActive()).isFalse(); + } + + @Test + @DisplayName("초대 코드 만료 시간 확인 - 3시간") + void inviteCode_ExpiresIn3Hours() { + // given + LocalDateTime now = LocalDateTime.now(); + RoomInviteCode code = RoomInviteCode.create("TEST1234", testRoom, testUser); + + // when + LocalDateTime expiresAt = code.getExpiresAt(); + + // then + assertThat(expiresAt).isAfter(now.plusHours(2).plusMinutes(59)); + assertThat(expiresAt).isBefore(now.plusHours(3).plusMinutes(1)); + } + + // ====================== 초대 코드 형식 검증 테스트 ====================== + + @Test + @DisplayName("초대 코드 형식 - 8자리 생성") + void inviteCode_Format_8Characters() { + // given + given(redisTemplate.opsForValue()).willReturn(valueOperations); + given(roomRepository.findById(1L)).willReturn(Optional.of(testRoom)); + given(userRepository.findById(1L)).willReturn(Optional.of(testUser)); + given(inviteCodeRepository.findByRoomIdAndCreatedByIdAndIsActiveTrue(1L, 1L)) + .willReturn(Optional.empty()); + given(inviteCodeRepository.existsByInviteCode(anyString())).willReturn(false); + given(inviteCodeRepository.save(any(RoomInviteCode.class))).willAnswer(i -> i.getArgument(0)); + + // when + RoomInviteCode result = inviteService.getOrCreateMyInviteCode(1L, 1L); + + // then + assertThat(result.getInviteCode()).hasSize(8); + assertThat(result.getInviteCode()).matches("^[A-Z2-9]{8}$"); // 대문자 + 숫자만 + } + + @Test + @DisplayName("초대 코드 형식 - 혼동 문자 제외 확인") + void inviteCode_Format_NoConfusingChars() { + // given + given(redisTemplate.opsForValue()).willReturn(valueOperations); + given(roomRepository.findById(1L)).willReturn(Optional.of(testRoom)); + given(userRepository.findById(1L)).willReturn(Optional.of(testUser)); + given(inviteCodeRepository.findByRoomIdAndCreatedByIdAndIsActiveTrue(1L, 1L)) + .willReturn(Optional.empty()); + given(inviteCodeRepository.existsByInviteCode(anyString())).willReturn(false); + given(inviteCodeRepository.save(any(RoomInviteCode.class))).willAnswer(i -> i.getArgument(0)); + + // when + RoomInviteCode result = inviteService.getOrCreateMyInviteCode(1L, 1L); + + // then + String code = result.getInviteCode(); + assertThat(code).doesNotContain("0", "O", "1", "I", "l"); // 혼동 문자 없음 + } + + // ====================== 엣지 케이스 테스트 ====================== + + @Test + @DisplayName("다중 사용자가 같은 방에 대한 초대 코드 생성") + void multipleUsers_CreateInviteCode_SameRoom() { + // given + given(redisTemplate.opsForValue()).willReturn(valueOperations); + + // given - User1의 코드 + given(roomRepository.findById(1L)).willReturn(Optional.of(testRoom)); + given(userRepository.findById(1L)).willReturn(Optional.of(testUser)); + given(inviteCodeRepository.findByRoomIdAndCreatedByIdAndIsActiveTrue(1L, 1L)) + .willReturn(Optional.empty()); + given(inviteCodeRepository.existsByInviteCode(anyString())).willReturn(false); + given(inviteCodeRepository.save(any(RoomInviteCode.class))).willAnswer(i -> i.getArgument(0)); + + // given - User2의 코드 + given(userRepository.findById(2L)).willReturn(Optional.of(testUser2)); + given(inviteCodeRepository.findByRoomIdAndCreatedByIdAndIsActiveTrue(1L, 2L)) + .willReturn(Optional.empty()); + + // when + RoomInviteCode code1 = inviteService.getOrCreateMyInviteCode(1L, 1L); + RoomInviteCode code2 = inviteService.getOrCreateMyInviteCode(1L, 2L); + + // then + assertThat(code1.getInviteCode()).isNotEqualTo(code2.getInviteCode()); // 다른 코드 + assertThat(code1.getCreatedBy()).isEqualTo(testUser); + assertThat(code2.getCreatedBy()).isEqualTo(testUser2); + assertThat(code1.getRoom()).isEqualTo(code2.getRoom()); // 같은 방 + + verify(inviteCodeRepository, times(2)).save(any(RoomInviteCode.class)); + } + + @Test + @DisplayName("초대 코드 재생성 제한 - 활성 코드 있으면 새로 생성 안 됨") + void createInviteCode_Limit_ExistingActiveCode() { + // given + given(redisTemplate.opsForValue()).willReturn(valueOperations); + given(roomRepository.findById(1L)).willReturn(Optional.of(testRoom)); + given(userRepository.findById(1L)).willReturn(Optional.of(testUser)); + given(inviteCodeRepository.findByRoomIdAndCreatedByIdAndIsActiveTrue(1L, 1L)) + .willReturn(Optional.of(testInviteCode)); + + // when + RoomInviteCode result1 = inviteService.getOrCreateMyInviteCode(1L, 1L); + RoomInviteCode result2 = inviteService.getOrCreateMyInviteCode(1L, 1L); + + // then + assertThat(result1).isSameAs(result2); // 같은 인스턴스 + assertThat(result1.getInviteCode()).isEqualTo("ABC12345"); + + verify(inviteCodeRepository, never()).save(any(RoomInviteCode.class)); // 저장 안 됨 + } + + @Test + @DisplayName("초대 코드 비활성화 후 재생성") + void createInviteCode_AfterDeactivation() { + // given + given(redisTemplate.opsForValue()).willReturn(valueOperations); + RoomInviteCode deactivatedCode = RoomInviteCode.create("OLD12345", testRoom, testUser); + deactivatedCode.deactivate(); + + given(roomRepository.findById(1L)).willReturn(Optional.of(testRoom)); + given(userRepository.findById(1L)).willReturn(Optional.of(testUser)); + given(inviteCodeRepository.findByRoomIdAndCreatedByIdAndIsActiveTrue(1L, 1L)) + .willReturn(Optional.of(deactivatedCode)); // 비활성 코드 반환 + given(inviteCodeRepository.existsByInviteCode(anyString())).willReturn(false); + given(inviteCodeRepository.save(any(RoomInviteCode.class))).willAnswer(i -> i.getArgument(0)); + + // when + RoomInviteCode result = inviteService.getOrCreateMyInviteCode(1L, 1L); + + // then + assertThat(result.getInviteCode()).isNotEqualTo("OLD12345"); // 새 코드 + assertThat(result.isValid()).isTrue(); + assertThat(deactivatedCode.isActive()).isFalse(); // 기존 코드는 비활성 유지 + + verify(inviteCodeRepository, times(1)).save(any(RoomInviteCode.class)); + } +}