From a8b16dca5a291e3e849be1df763a002585bea0bf Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 17 Sep 2026 19:15:58 +0900 Subject: [PATCH] =?UTF-8?q?fix(live-room):=20=EC=A0=9C=ED=95=9C=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=EC=9D=98=20=EC=9E=85=EC=9E=A5?= =?UTF-8?q?=EA=B3=BC=20=ED=86=A0=ED=81=B0=20=EB=B0=9C=EA=B8=89=EC=9D=84=20?= =?UTF-8?q?=EC=B0=A8=EB=8B=A8=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/live/room/LiveRoomService.kt | 20 + .../sodalive/live/room/LiveRoomServiceTest.kt | 429 ++++++++++++++++++ 2 files changed, 449 insertions(+) create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomServiceTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt index c724cbaa..736cab4a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt @@ -812,6 +812,14 @@ class LiveRoomService( ) } + if ( + member.role == MemberRole.CREATOR && + room.member!!.id!! != member.id!! && + !room.isAvailableJoinCreator + ) { + throw SodaException(messageKey = "live.room.not_found") + } + if (room.member!!.id!! != member.id!! && !member.canEnter(room.genderRestriction)) { throw SodaException(messageKey = "live.room.gender_restricted") } @@ -975,6 +983,18 @@ class LiveRoomService( throw SodaException(messageKey = "live.room.info_not_found") } + if ( + member.role == MemberRole.CREATOR && + room.member!!.id!! != member.id!! && + !room.isAvailableJoinCreator + ) { + throw SodaException(messageKey = "live.room.not_found") + } + + if (room.member!!.id!! != member.id!! && !member.canEnter(room.genderRestriction)) { + throw SodaException(messageKey = "live.room.gender_restricted") + } + val currentTimeStamp = Date().time val expireTimestamp = (currentTimeStamp + (60 * 60 * 24 * 1000)) / 1000 diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomServiceTest.kt new file mode 100644 index 00000000..3ff85a8f --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomServiceTest.kt @@ -0,0 +1,429 @@ +package kr.co.vividnext.sodalive.live.room + +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.agora.RtcTokenBuilder +import kr.co.vividnext.sodalive.agora.RtmTokenBuilder +import kr.co.vividnext.sodalive.aws.s3.S3Uploader +import kr.co.vividnext.sodalive.can.CanRepository +import kr.co.vividnext.sodalive.can.charge.ChargeRepository +import kr.co.vividnext.sodalive.can.payment.CanPaymentService +import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository +import kr.co.vividnext.sodalive.explorer.profile.CreatorDonationRankingService +import kr.co.vividnext.sodalive.fcm.PushTokenRepository +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource +import kr.co.vividnext.sodalive.live.reservation.LiveReservationRepository +import kr.co.vividnext.sodalive.live.room.cancel.LiveRoomCancelRepository +import kr.co.vividnext.sodalive.live.room.info.LiveRoomInfo +import kr.co.vividnext.sodalive.live.room.info.LiveRoomInfoRedisRepository +import kr.co.vividnext.sodalive.live.room.kickout.LiveRoomKickOutService +import kr.co.vividnext.sodalive.live.room.menu.LiveRoomMenuService +import kr.co.vividnext.sodalive.live.room.visit.LiveRoomVisitService +import kr.co.vividnext.sodalive.live.roulette.NewRouletteRepository +import kr.co.vividnext.sodalive.live.signature.SignatureCanRepository +import kr.co.vividnext.sodalive.live.tag.LiveTagRepository +import kr.co.vividnext.sodalive.member.Gender +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.auth.Auth +import kr.co.vividnext.sodalive.member.block.BlockMemberRepository +import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.mockito.Mockito +import java.time.LocalDateTime +import java.util.Optional + +class LiveRoomServiceTest { + private lateinit var repository: LiveRoomRepository + private lateinit var roomInfoRepository: LiveRoomInfoRedisRepository + private lateinit var canPaymentService: CanPaymentService + private lateinit var memberRepository: MemberRepository + private lateinit var blockMemberRepository: BlockMemberRepository + private lateinit var explorerQueryRepository: ExplorerQueryRepository + private lateinit var rouletteRepository: NewRouletteRepository + private lateinit var pushTokenRepository: PushTokenRepository + private lateinit var rtcTokenBuilder: RtcTokenBuilder + private lateinit var rtmTokenBuilder: RtmTokenBuilder + private lateinit var service: LiveRoomService + + @BeforeEach + fun setup() { + repository = mock() + roomInfoRepository = mock() + canPaymentService = mock() + memberRepository = mock() + blockMemberRepository = mock() + explorerQueryRepository = mock() + rouletteRepository = mock() + pushTokenRepository = mock() + rtcTokenBuilder = mock() + rtmTokenBuilder = mock() + + service = LiveRoomService( + menuService = mock(), + messageSource = mock(), + langContext = LangContext(), + repository = repository, + rouletteRepository = rouletteRepository, + roomInfoRepository = roomInfoRepository, + roomCancelRepository = mock(), + kickOutService = mock(), + blockMemberRepository = blockMemberRepository, + signatureCanRepository = mock(), + applicationEventPublisher = mock(), + useCanCalculateRepository = mock(), + reservationRepository = mock(), + explorerQueryRepository = explorerQueryRepository, + creatorDonationRankingService = mock(), + roomVisitService = mock(), + canPaymentService = canPaymentService, + chargeRepository = mock(), + pushTokenRepository = pushTokenRepository, + memberRepository = memberRepository, + tagRepository = mock(), + memberContentPreferenceService = mock(), + canRepository = mock(), + objectMapper = ObjectMapper(), + s3Uploader = mock(), + rtcTokenBuilder = rtcTokenBuilder, + rtmTokenBuilder = rtmTokenBuilder, + agoraAppId = "test-agora-app-id", + agoraAppCertificate = "test-agora-app-certificate", + coverImageBucket = "test-cover-image-bucket", + cloudFrontHost = "https://test-cloudfront-host" + ) + } + + @Test + @DisplayName("다른 크리에이터는 크리에이터 입장 제한 방에 입장할 수 없다") + fun shouldRejectDifferentCreatorBeforePaymentOrRoomInfoMutation() { + val owner = createMember(id = 1L, role = MemberRole.CREATOR) + val enteringCreator = createMember(id = 2L, role = MemberRole.CREATOR).also { it.pgChargeCan = 10 } + val room = createRoom(owner = owner, price = 10) + prepareAllowedEntry(room) + Mockito.`when`(memberRepository.findById(enteringCreator.id!!)).thenReturn(Optional.of(enteringCreator)) + + val exception = assertThrows { + service.enterLive(EnterOrQuitLiveRoomRequest(roomId = room.id!!, container = "web"), enteringCreator) + } + + assertEquals("live.room.not_found", exception.messageKey) + Mockito.verifyNoInteractions(roomInfoRepository, canPaymentService) + } + + @Test + @DisplayName("성별 제한에 맞지 않는 일반 사용자는 입장할 수 없다") + fun shouldRejectGenderMismatchedUserBeforePaymentOrRoomInfoMutation() { + val owner = createMember(id = 1L, role = MemberRole.CREATOR) + val member = createMember(id = 2L, gender = Gender.FEMALE) + val room = createRoom(owner = owner, price = 10, genderRestriction = GenderRestriction.MALE_ONLY) + Mockito.`when`(repository.getLiveRoom(room.id!!)).thenReturn(room) + + val exception = assertThrows { + service.enterLive(EnterOrQuitLiveRoomRequest(roomId = room.id!!, container = "web"), member) + } + + assertEquals("live.room.gender_restricted", exception.messageKey) + Mockito.verifyNoInteractions(roomInfoRepository, canPaymentService) + } + + @Test + @DisplayName("방 생성자는 크리에이터 및 성별 제한과 무관하게 입장할 수 있다") + fun shouldAllowRoomOwner() { + val owner = createMember(id = 1L, role = MemberRole.CREATOR, gender = Gender.MALE) + val room = createRoom(owner = owner, genderRestriction = GenderRestriction.FEMALE_ONLY) + val roomInfo = prepareAllowedEntry(room) + + service.enterLive(EnterOrQuitLiveRoomRequest(roomId = room.id!!, container = "web"), owner) + + assertEquals(1, roomInfo.speakerCount) + Mockito.verify(roomInfoRepository).save(roomInfo) + } + + @Test + @DisplayName("일반 사용자는 조건에 맞으면 크리에이터 입장 제한 방에 입장할 수 있다") + fun shouldAllowEligibleUser() { + val owner = createMember(id = 1L, role = MemberRole.CREATOR) + val member = createMember(id = 2L, gender = Gender.MALE) + val room = createRoom(owner = owner, genderRestriction = GenderRestriction.MALE_ONLY) + val roomInfo = prepareAllowedEntry(room) + + service.enterLive(EnterOrQuitLiveRoomRequest(roomId = room.id!!, container = "web"), member) + + assertEquals(1, roomInfo.listenerCount) + Mockito.verify(roomInfoRepository).save(roomInfo) + } + + @Test + @DisplayName("성별 미설정 사용자는 기존 성별 제한 정책대로 입장할 수 있다") + fun shouldAllowUserWithNoGender() { + val owner = createMember(id = 1L, role = MemberRole.CREATOR) + val member = createMember(id = 2L, gender = Gender.NONE) + val room = createRoom(owner = owner, genderRestriction = GenderRestriction.MALE_ONLY) + val roomInfo = prepareAllowedEntry(room) + + service.enterLive(EnterOrQuitLiveRoomRequest(roomId = room.id!!, container = "web"), member) + + assertEquals(1, roomInfo.listenerCount) + Mockito.verify(roomInfoRepository).save(roomInfo) + } + + @Test + @DisplayName("다른 크리에이터는 제한 방 정보 조회로 토큰을 발급받을 수 없다") + fun shouldRejectDifferentCreatorFromRoomInfoBeforeTokenGeneration() { + val owner = createMember(id = 1L, role = MemberRole.CREATOR) + val viewer = createMember(id = 2L, role = MemberRole.CREATOR) + val room = createRoom(owner = owner) + prepareSuccessfulRoomInfo(room) + + val exception = assertThrows { + service.getRoomInfo(roomId = room.id!!, member = viewer) + } + + assertEquals("live.room.not_found", exception.messageKey) + Mockito.verifyNoInteractions(rtcTokenBuilder, rtmTokenBuilder) + } + + @Test + @DisplayName("성별 제한에 맞지 않는 사용자는 방 정보 조회로 토큰을 발급받을 수 없다") + fun shouldRejectGenderMismatchedNonOwnerFromRoomInfoBeforeTokenGeneration() { + val owner = createMember(id = 1L, role = MemberRole.CREATOR) + val viewer = createMember(id = 2L, gender = Gender.FEMALE) + val room = createRoom(owner = owner, genderRestriction = GenderRestriction.MALE_ONLY) + prepareSuccessfulRoomInfo(room) + + val exception = assertThrows { + service.getRoomInfo(roomId = room.id!!, member = viewer) + } + + assertEquals("live.room.gender_restricted", exception.messageKey) + Mockito.verifyNoInteractions(rtcTokenBuilder, rtmTokenBuilder) + } + + @Test + @DisplayName("크리에이터와 성별 제한이 함께 적용되면 크리에이터 제한을 먼저 반환한다") + fun shouldPrioritizeCreatorRestrictionOverGenderRestrictionInRoomInfo() { + val owner = createMember(id = 1L, role = MemberRole.CREATOR) + val viewer = createMember(id = 2L, role = MemberRole.CREATOR, gender = Gender.FEMALE) + val room = createRoom(owner = owner, genderRestriction = GenderRestriction.MALE_ONLY) + prepareSuccessfulRoomInfo(room) + + val exception = assertThrows { + service.getRoomInfo(roomId = room.id!!, member = viewer) + } + + assertEquals("live.room.not_found", exception.messageKey) + Mockito.verifyNoInteractions(rtcTokenBuilder, rtmTokenBuilder) + } + + @Test + @DisplayName("입장 거절 뒤 같은 제한 방 정보 조회도 토큰을 발급하지 않는다") + fun shouldNotGenerateTokensAfterEnterLiveRejectsDifferentCreator() { + val owner = createMember(id = 1L, role = MemberRole.CREATOR) + val viewer = createMember(id = 2L, role = MemberRole.CREATOR) + val room = createRoom(owner = owner) + prepareSuccessfulRoomInfo(room) + Mockito.`when`(repository.getLiveRoom(room.id!!)).thenReturn(room) + + val enterException = assertThrows { + service.enterLive(EnterOrQuitLiveRoomRequest(roomId = room.id!!, container = "web"), viewer) + } + val roomInfoException = assertThrows { + service.getRoomInfo(roomId = room.id!!, member = viewer) + } + + assertEquals("live.room.not_found", enterException.messageKey) + assertEquals("live.room.not_found", roomInfoException.messageKey) + Mockito.verifyNoInteractions(rtcTokenBuilder, rtmTokenBuilder) + } + + @Test + @DisplayName("방 생성자는 모든 크리에이터와 성별 제한이 있어도 방 정보 토큰을 발급받는다") + fun shouldAllowRoomOwnerToGetRoomInfoTokens() { + val owner = createMember(id = 1L, role = MemberRole.CREATOR, gender = Gender.MALE) + val room = createRoom(owner = owner, genderRestriction = GenderRestriction.FEMALE_ONLY) + prepareSuccessfulRoomInfo(room) + + assertTokenizedRoomInfo(room, owner) + } + + @Test + @DisplayName("성별이 맞는 일반 사용자는 제한 방 정보 토큰을 발급받는다") + fun shouldAllowGenderMatchedUserToGetRoomInfoTokens() { + val owner = createMember(id = 1L, role = MemberRole.CREATOR) + val viewer = createMember(id = 2L, gender = Gender.MALE) + val room = createRoom(owner = owner, genderRestriction = GenderRestriction.MALE_ONLY) + prepareSuccessfulRoomInfo(room) + + assertTokenizedRoomInfo(room, viewer) + } + + @Test + @DisplayName("크리에이터 입장이 허용된 다른 크리에이터는 방 정보 토큰을 발급받는다") + fun shouldAllowDifferentCreatorWhenCreatorJoiningIsEnabledToGetRoomInfoTokens() { + val owner = createMember(id = 1L, role = MemberRole.CREATOR) + val viewer = createMember(id = 2L, role = MemberRole.CREATOR, gender = Gender.MALE) + val room = createRoom( + owner = owner, + isAvailableJoinCreator = true, + genderRestriction = GenderRestriction.MALE_ONLY + ) + prepareSuccessfulRoomInfo(room) + + assertTokenizedRoomInfo(room, viewer) + } + + @Test + @DisplayName("인증 성별은 프로필 성별보다 우선하여 방 정보 제한을 판정한다") + fun shouldUseAuthGenderBeforeProfileGenderForRoomInfoTokens() { + val owner = createMember(id = 1L, role = MemberRole.CREATOR) + val viewer = createMember(id = 2L, gender = Gender.MALE) + viewer.auth = Auth( + name = "viewer", + birth = "20000101", + uniqueCi = "unique-ci", + di = "di", + gender = 2 + ) + val room = createRoom(owner = owner, genderRestriction = GenderRestriction.FEMALE_ONLY) + prepareSuccessfulRoomInfo(room) + + assertTokenizedRoomInfo(room, viewer) + } + + @Test + @DisplayName("성별 미설정 사용자는 기존 정책대로 방 정보 토큰을 발급받는다") + fun shouldAllowUserWithNoGenderToGetRoomInfoTokens() { + val owner = createMember(id = 1L, role = MemberRole.CREATOR) + val viewer = createMember(id = 2L, gender = Gender.NONE) + val room = createRoom(owner = owner, genderRestriction = GenderRestriction.MALE_ONLY) + prepareSuccessfulRoomInfo(room) + + assertTokenizedRoomInfo(room, viewer) + } + + @Test + @DisplayName("상호 차단된 사용자는 기존 방 정보 예외로 토큰을 발급받을 수 없다") + fun shouldRejectMutuallyBlockedUserFromRoomInfoBeforeTokenGeneration() { + val owner = createMember(id = 1L, role = MemberRole.CREATOR) + val viewer = createMember(id = 2L, gender = Gender.MALE) + val room = createRoom(owner = owner, genderRestriction = GenderRestriction.MALE_ONLY) + prepareSuccessfulRoomInfo(room) + Mockito.`when`(blockMemberRepository.isBlocked(Mockito.anyLong(), Mockito.anyLong())).thenReturn(true) + + val exception = assertThrows { + service.getRoomInfo(roomId = room.id!!, member = viewer) + } + + assertEquals("live.room.info_not_found", exception.messageKey) + Mockito.verifyNoInteractions(rtcTokenBuilder, rtmTokenBuilder) + } + + private fun prepareAllowedEntry(room: LiveRoom): LiveRoomInfo { + val roomInfo = LiveRoomInfo(roomId = room.id!!) + Mockito.`when`(repository.getLiveRoom(room.id!!)).thenReturn(room) + Mockito.`when`(roomInfoRepository.findById(room.id!!)).thenReturn(Optional.of(roomInfo)) + Mockito.`when`(roomInfoRepository.save(roomInfo)).thenReturn(roomInfo) + return roomInfo + } + + private fun prepareSuccessfulRoomInfo(room: LiveRoom): LiveRoomInfo { + val roomInfo = LiveRoomInfo(roomId = room.id!!) + room.member!!.isVisibleDonationRank = false + room.channelName = "SODA_LIVE_CHANNEL_TEST" + + Mockito.`when`(roomInfoRepository.findById(room.id!!)).thenReturn(Optional.of(roomInfo)) + Mockito.`when`(repository.findById(room.id!!)).thenReturn(Optional.of(room)) + Mockito.`when`(blockMemberRepository.isBlocked(Mockito.anyLong(), Mockito.anyLong())).thenReturn(false) + Mockito.`when`( + rtcTokenBuilder.buildTokenWithUid( + Mockito.anyString(), + Mockito.anyString(), + Mockito.anyString(), + Mockito.anyString(), + Mockito.anyInt() + ) + ).thenReturn("rtc-token") + Mockito.`when`( + rtmTokenBuilder.buildToken( + Mockito.anyString(), + Mockito.anyString(), + Mockito.anyString(), + Mockito.anyInt() + ) + ).thenReturn("rtm-token") + Mockito.`when`(explorerQueryRepository.getNotificationUserIds(room.member!!.id!!)).thenReturn(emptyList()) + Mockito.`when`(rouletteRepository.findByCreatorId(room.member!!.id!!)).thenReturn(emptyList()) + Mockito.`when`(pushTokenRepository.findByMemberId(room.member!!.id!!)).thenReturn(emptyList()) + return roomInfo + } + + private fun assertTokenizedRoomInfo(room: LiveRoom, member: Member) { + val response = service.getRoomInfo(roomId = room.id!!, member = member) + + assertEquals(room.channelName, response.channelName) + assertEquals("rtc-token", response.rtcToken) + assertEquals("rtm-token", response.rtmToken) + assertEquals("rtc-token", response.v2vWorkerToken) + Mockito.verify(rtcTokenBuilder, Mockito.times(2)).buildTokenWithUid( + Mockito.anyString(), + Mockito.anyString(), + Mockito.anyString(), + Mockito.anyString(), + Mockito.anyInt() + ) + Mockito.verify(rtmTokenBuilder).buildToken( + Mockito.anyString(), + Mockito.anyString(), + Mockito.anyString(), + Mockito.anyInt() + ) + } + + private fun createRoom( + owner: Member, + price: Int = 0, + isAvailableJoinCreator: Boolean = false, + genderRestriction: GenderRestriction = GenderRestriction.ALL + ): LiveRoom { + return LiveRoom( + title = "test room", + notice = "test notice", + beginDateTime = LocalDateTime.now(), + numberOfPeople = 10, + coverImage = "cover/image.png", + isAdult = false, + price = price, + isAvailableJoinCreator = isAvailableJoinCreator, + genderRestriction = genderRestriction + ).also { + it.id = 100L + it.member = owner + } + } + + private fun createMember( + id: Long, + role: MemberRole = MemberRole.USER, + gender: Gender = Gender.NONE + ): Member { + return Member( + email = "member$id@test.com", + password = "password", + nickname = "member$id", + gender = gender, + role = role + ).also { it.id = id } + } + + private inline fun mock(): T { + return Mockito.mock(T::class.java) + } +}