diff --git a/scripts/sql/mail-reservation-queries.sql b/scripts/sql/mail-reservation-queries.sql index 0996dd1f..1fe6a465 100644 --- a/scripts/sql/mail-reservation-queries.sql +++ b/scripts/sql/mail-reservation-queries.sql @@ -1,45 +1,66 @@ --- 메일 예약 DB 확인용 SQL (mail_reservation / mail 테이블) - --- 1. 예약 전체 목록 (예약 시각 기준 최신순) SELECT r.id AS reservation_id, - r.mail_id, + r.status AS reservation_status, + r.reservation_time AS reservation_time, + m.id AS mail_id, + m.sender_email_address, + m.mail_subject, + m.mail_body, + m.body_format, + GROUP_CONCAT( + CASE + WHEN ra.type = 'TO' THEN ra.email_address + END + ORDER BY ra.id SEPARATOR ',' + ) AS to_addresses, + GROUP_CONCAT( + CASE + WHEN ra.type = 'CC' THEN ra.email_address + END + ORDER BY ra.id SEPARATOR ',' + ) AS cc_addresses, + GROUP_CONCAT( + CASE + WHEN ra.type = 'BCC' THEN ra.email_address + END + ORDER BY ra.id SEPARATOR ',' + ) AS bcc_addresses +FROM + mail_reservation r + JOIN mail m ON r.mail_id = m.id + LEFT JOIN mail_recipient_address ra ON ra.mail_id = m.id +WHERE + m.sender_email_address = 'glen.urssu@gmail.com' + -- 예약(미발송)만 보고 싶으면 아래 주석 해제 + AND r.status IN ('SCHEDULED', 'PENDING_SEND') +GROUP BY + r.id, + r.status, r.reservation_time, + m.id, m.sender_email_address, m.mail_subject, + m.mail_body, m.body_format -FROM mail_reservation r -LEFT JOIN mail m ON m.id = r.mail_id -ORDER BY r.reservation_time DESC; +ORDER BY r.reservation_time;\G --- 2. 예약 건수만 확인 -SELECT COUNT(*) AS reservation_count FROM mail_reservation; --- 3. 아직 발송 시점이 안 된 예약 (미래 시각) — 스케줄러가 아직 안 건드림 SELECT - r.id, - r.mail_id, - r.reservation_time, - m.mail_subject -FROM mail_reservation r -LEFT JOIN mail m ON m.id = r.mail_id -WHERE r.reservation_time > NOW() -ORDER BY r.reservation_time; - --- 4. 발송 대상이어야 하는데 남아 있는 예약 (과거 시각) — 스케줄러가 처리 못 한 것 -SELECT - r.id, - r.mail_id, - r.reservation_time, + r.id AS reservation_id, + r.status AS reservation_status, + r.reservation_time AS reservation_time, + m.id AS mail_id, + m.sender_email_address, m.mail_subject, - m.sender_email_address -FROM mail_reservation r -LEFT JOIN mail m ON m.id = r.mail_id -WHERE r.reservation_time <= NOW() -ORDER BY r.reservation_time; - --- 5. 예약은 있는데 메일이 삭제된 경우 (스케줄러에서 warn 나는 경우) -SELECT r.id, r.mail_id, r.reservation_time -FROM mail_reservation r -LEFT JOIN mail m ON m.id = r.mail_id -WHERE m.id IS NULL; + m.mail_body, + m.body_format, + ra.type AS recipient_type, -- TO / CC / BCC + ra.email_address AS recipient_address +FROM + mail_reservation r + JOIN mail m ON r.mail_id = m.id + LEFT JOIN mail_recipient_address ra ON ra.mail_id = m.id +WHERE + m.sender_email_address = 'glen.urssu@gmail.com' + AND r.status IN ('SCHEDULED', 'PENDING_SEND') -- 필요 시 예약건만 +ORDER BY r.reservation_time, r.id, ra.type, ra.id; diff --git a/src/main/kotlin/com/yourssu/scouter/ats/business/domain/applicant/FormResponseToApplicantProcessor.kt b/src/main/kotlin/com/yourssu/scouter/ats/business/domain/applicant/FormResponseToApplicantProcessor.kt index 0997afbf..1962d6cf 100644 --- a/src/main/kotlin/com/yourssu/scouter/ats/business/domain/applicant/FormResponseToApplicantProcessor.kt +++ b/src/main/kotlin/com/yourssu/scouter/ats/business/domain/applicant/FormResponseToApplicantProcessor.kt @@ -55,7 +55,10 @@ class FormResponseToApplicantProcessor( applicationDateTime = userResponse.createTime, applicationSemester = applicationSemester, academicSemester = userResponse.getAnswer(question.academicSemesterQuestion) ?: "", - availableTimes = availableTimeParser.parse(userResponse.getAll(question.availableTimeQuestion)) + availableTimes = availableTimeParser.parse( + responseItems = userResponse.responseItems, + availableTimeQuestion = question.availableTimeQuestion, + ), ) return ApplicantSyncInfo(applicant, formId, userResponse.responseId) @@ -88,7 +91,10 @@ class FormResponseToApplicantProcessor( applicationDateTime = userResponse.createTime, applicationSemester = applicantSyncMapping.applicationSemester, academicSemester = userResponse.getAnswer(applicantSyncMapping.academicSemesterQuestion) ?: "", - availableTimes = availableTimeParser.parse(userResponse.getAll(applicantSyncMapping.availableTimeQuestion)), + availableTimes = availableTimeParser.parse( + responseItems = userResponse.responseItems, + availableTimeQuestion = applicantSyncMapping.availableTimeQuestion, + ), ) return ApplicantSyncInfo(applicant, applicantSyncMapping.formId, userResponse.responseId) diff --git a/src/main/kotlin/com/yourssu/scouter/common/application/domain/mail/MailReservationDetailResponse.kt b/src/main/kotlin/com/yourssu/scouter/common/application/domain/mail/MailReservationDetailResponse.kt index 11caa66d..d0570a15 100644 --- a/src/main/kotlin/com/yourssu/scouter/common/application/domain/mail/MailReservationDetailResponse.kt +++ b/src/main/kotlin/com/yourssu/scouter/common/application/domain/mail/MailReservationDetailResponse.kt @@ -11,6 +11,8 @@ data class MailReservationDetailResponse( val reservationTime: Instant, @Schema(description = "예약 상태", example = "SCHEDULED", allowableValues = ["SCHEDULED", "PENDING_SEND", "SENT"]) val status: MailReservationStatus, + @Schema(description = "발신자 이메일", example = "sender@example.com") + val senderEmailAddress: String, val mailSubject: String, val mailBody: String, val bodyFormat: String, @@ -26,6 +28,7 @@ data class MailReservationDetailResponse( mailId = detail.mailId, reservationTime = detail.reservationTime, status = detail.status, + senderEmailAddress = detail.senderEmailAddress, mailSubject = detail.mailSubject, mailBody = detail.mailBody, bodyFormat = detail.bodyFormat.name, diff --git a/src/main/kotlin/com/yourssu/scouter/common/application/domain/mail/MailReservationListResponse.kt b/src/main/kotlin/com/yourssu/scouter/common/application/domain/mail/MailReservationListResponse.kt index b8231d74..63748829 100644 --- a/src/main/kotlin/com/yourssu/scouter/common/application/domain/mail/MailReservationListResponse.kt +++ b/src/main/kotlin/com/yourssu/scouter/common/application/domain/mail/MailReservationListResponse.kt @@ -11,6 +11,8 @@ data class MailReservationListItem( val reservationTime: Instant, @Schema(description = "예약 상태", example = "SCHEDULED", allowableValues = ["SCHEDULED", "PENDING_SEND", "SENT"]) val status: MailReservationStatus, + @Schema(description = "발신자 이메일", example = "sender@example.com") + val senderEmailAddress: String, val mailSubject: String, @Schema(description = "대표 수신자 이메일 (수신자가 없으면 null)", example = "receiver@example.com", nullable = true) val primaryReceiverEmailAddress: String?, @@ -30,6 +32,7 @@ data class MailReservationListResponse( mailId = detail.mailId, reservationTime = detail.reservationTime, status = detail.status, + senderEmailAddress = detail.senderEmailAddress, mailSubject = detail.mailSubject, primaryReceiverEmailAddress = detail.receiverEmailAddresses.firstOrNull(), hasAttachments = detail.hasAttachments, diff --git a/src/main/kotlin/com/yourssu/scouter/common/business/domain/mail/MailService.kt b/src/main/kotlin/com/yourssu/scouter/common/business/domain/mail/MailService.kt index 85bb4723..d5157fe1 100644 --- a/src/main/kotlin/com/yourssu/scouter/common/business/domain/mail/MailService.kt +++ b/src/main/kotlin/com/yourssu/scouter/common/business/domain/mail/MailService.kt @@ -17,6 +17,7 @@ import com.yourssu.scouter.common.implement.domain.mail.MailWriter import com.yourssu.scouter.common.implement.domain.user.UserReader import com.yourssu.scouter.common.implement.support.exception.MailReservationAlreadyProcessedException import com.yourssu.scouter.common.implement.support.exception.MailReservationNotYetDueException +import com.yourssu.scouter.hrms.business.domain.member.MemberPrivacyService import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import java.time.Instant @@ -33,6 +34,7 @@ class MailService( private val mailRepository: MailRepository, private val oauth2Service: OAuth2Service, private val mailSender: MailSender, + private val memberPrivacyService: MemberPrivacyService, ) { companion object { private val log = LoggerFactory.getLogger(MailService::class.java) @@ -65,8 +67,13 @@ class MailService( fun getPendingReservationStatuses(userId: Long): List { val user = userReader.readById(userId) val now = Instant.now() - val senderEmail = user.getEmailAddress() - val reservations = mailReservationReader.readAllBeforeBySenderEmail(now, senderEmail) + val reservations = + if (memberPrivacyService.isPrivilegedUser(userId)) { + mailReservationReader.readAllBefore(now) + } else { + val senderEmails = memberPrivacyService.getActiveTeamMemberEmails(userId).toList() + mailReservationReader.readAllBeforeBySenderEmails(now, senderEmails) + } return reservations.map { reservation -> val mail = mailRepository.findById(reservation.mailId) ?: run { log.warn("예약의 메일을 찾을 수 없음: reservationId={}, mailId={}", reservation.id, reservation.mailId) @@ -195,9 +202,13 @@ class MailService( } fun getUserMailReservations(userId: Long): List { - val user = userReader.readById(userId) - val senderEmail = user.getEmailAddress() - val reservations = mailReservationReader.readAllBySenderEmail(senderEmail) + val reservations = + if (memberPrivacyService.isPrivilegedUser(userId)) { + mailReservationReader.readAll() + } else { + val senderEmails = memberPrivacyService.getActiveTeamMemberEmails(userId).toList() + mailReservationReader.readAllBySenderEmails(senderEmails) + } return reservations.map { reservation -> val mail = mailRepository.findById(reservation.mailId) @@ -212,7 +223,6 @@ class MailService( userId: Long, reservationId: Long, ): MailReservationDetail { - val user = userReader.readById(userId) val reservation = mailReservationReader.readById(reservationId) ?: throw MailReservationNotFoundException("예약을 찾을 수 없습니다. reservationId=$reservationId") @@ -221,8 +231,10 @@ class MailService( mailRepository.findById(reservation.mailId) ?: throw MailReservationNotFoundException("예약 메일을 찾을 수 없습니다. reservationId=$reservationId, mailId=${reservation.mailId}") - val senderEmail = user.getEmailAddress() - if (mail.senderEmailAddress != senderEmail) { + val canAccess = + memberPrivacyService.isPrivilegedUser(userId) || + memberPrivacyService.getActiveTeamMemberEmails(userId).contains(mail.senderEmailAddress) + if (!canAccess) { throw MailReservationAccessDeniedException("예약에 접근할 수 없습니다. reservationId=$reservationId") } diff --git a/src/main/kotlin/com/yourssu/scouter/common/implement/domain/mail/MailReservationReader.kt b/src/main/kotlin/com/yourssu/scouter/common/implement/domain/mail/MailReservationReader.kt index 8c2ed32e..2b625fd4 100644 --- a/src/main/kotlin/com/yourssu/scouter/common/implement/domain/mail/MailReservationReader.kt +++ b/src/main/kotlin/com/yourssu/scouter/common/implement/domain/mail/MailReservationReader.kt @@ -10,6 +10,10 @@ class MailReservationReader( private val mailReservationRepository: MailReservationRepository, ) { + fun readAll(): List { + return mailReservationRepository.findAll() + } + fun readAllBefore(time: Instant): List { return mailReservationRepository.findAllByReservationTimeLessThanEqual(time) } @@ -23,10 +27,18 @@ class MailReservationReader( return mailReservationRepository.findAllByReservationTimeLessThanEqualAndSenderEmail(time, senderEmail) } + fun readAllBeforeBySenderEmails(time: Instant, senderEmails: List): List { + return mailReservationRepository.findAllByReservationTimeLessThanEqualAndSenderEmails(time, senderEmails) + } + fun readAllBySenderEmail(senderEmail: String): List { return mailReservationRepository.findAllBySenderEmail(senderEmail) } + fun readAllBySenderEmails(senderEmails: List): List { + return mailReservationRepository.findAllBySenderEmails(senderEmails) + } + fun readAllBySenderEmailAndReservationTimeBetween( senderEmail: String, from: Instant, @@ -35,6 +47,14 @@ class MailReservationReader( return mailReservationRepository.findAllBySenderEmailAndReservationTimeBetween(senderEmail, from, to) } + fun readAllBySenderEmailsAndReservationTimeBetween( + senderEmails: List, + from: Instant, + to: Instant, + ): List { + return mailReservationRepository.findAllBySenderEmailsAndReservationTimeBetween(senderEmails, from, to) + } + fun readById(id: Long): MailReservation? { return mailReservationRepository.findById(id) } diff --git a/src/main/kotlin/com/yourssu/scouter/common/implement/domain/mail/MailReservationRepository.kt b/src/main/kotlin/com/yourssu/scouter/common/implement/domain/mail/MailReservationRepository.kt index 31fcbef7..334ae844 100644 --- a/src/main/kotlin/com/yourssu/scouter/common/implement/domain/mail/MailReservationRepository.kt +++ b/src/main/kotlin/com/yourssu/scouter/common/implement/domain/mail/MailReservationRepository.kt @@ -6,6 +6,8 @@ interface MailReservationRepository { fun save(mailReservation: MailReservation): MailReservation + fun findAll(): List + fun findAllByReservationTimeLessThanEqual(reservationTime: Instant): List fun findAllByReservationTimeLessThanEqualAndStatusNot( @@ -15,14 +17,24 @@ interface MailReservationRepository { fun findAllByReservationTimeLessThanEqualAndSenderEmail(time: Instant, senderEmail: String): List + fun findAllByReservationTimeLessThanEqualAndSenderEmails(time: Instant, senderEmails: List): List + fun findAllBySenderEmail(senderEmail: String): List + fun findAllBySenderEmails(senderEmails: List): List + fun findAllBySenderEmailAndReservationTimeBetween( senderEmail: String, from: Instant, to: Instant, ): List + fun findAllBySenderEmailsAndReservationTimeBetween( + senderEmails: List, + from: Instant, + to: Instant, + ): List + fun findById(id: Long): MailReservation? fun deleteById(id: Long) diff --git a/src/main/kotlin/com/yourssu/scouter/common/storage/domain/mail/JpaMailReservationRepository.kt b/src/main/kotlin/com/yourssu/scouter/common/storage/domain/mail/JpaMailReservationRepository.kt index aa82ed8c..ecaad52c 100644 --- a/src/main/kotlin/com/yourssu/scouter/common/storage/domain/mail/JpaMailReservationRepository.kt +++ b/src/main/kotlin/com/yourssu/scouter/common/storage/domain/mail/JpaMailReservationRepository.kt @@ -24,6 +24,15 @@ interface JpaMailReservationRepository : JpaRepository + @Query( + "SELECT r FROM MailReservationEntity r, MailEntity m WHERE r.mailId = m.id " + + "AND r.reservationTime <= :time AND m.senderEmailAddress IN :senderEmails", + ) + fun findAllByReservationTimeLessThanEqualAndSenderEmails( + @Param("time") time: Instant, + @Param("senderEmails") senderEmails: List, + ): List + @Query( "SELECT r FROM MailReservationEntity r, MailEntity m WHERE r.mailId = m.id " + "AND m.senderEmailAddress = :senderEmail", @@ -32,6 +41,14 @@ interface JpaMailReservationRepository : JpaRepository + @Query( + "SELECT r FROM MailReservationEntity r, MailEntity m WHERE r.mailId = m.id " + + "AND m.senderEmailAddress IN :senderEmails", + ) + fun findAllBySenderEmails( + @Param("senderEmails") senderEmails: List, + ): List + @Query( "SELECT r FROM MailReservationEntity r, MailEntity m WHERE r.mailId = m.id " + "AND m.senderEmailAddress = :senderEmail " + @@ -42,4 +59,15 @@ interface JpaMailReservationRepository : JpaRepository + + @Query( + "SELECT r FROM MailReservationEntity r, MailEntity m WHERE r.mailId = m.id " + + "AND m.senderEmailAddress IN :senderEmails " + + "AND r.reservationTime BETWEEN :from AND :to", + ) + fun findAllBySenderEmailsAndReservationTimeBetween( + @Param("senderEmails") senderEmails: List, + @Param("from") from: Instant, + @Param("to") to: Instant, + ): List } diff --git a/src/main/kotlin/com/yourssu/scouter/common/storage/domain/mail/MailReservationRepositoryImpl.kt b/src/main/kotlin/com/yourssu/scouter/common/storage/domain/mail/MailReservationRepositoryImpl.kt index 0b3287ff..48ad8526 100644 --- a/src/main/kotlin/com/yourssu/scouter/common/storage/domain/mail/MailReservationRepositoryImpl.kt +++ b/src/main/kotlin/com/yourssu/scouter/common/storage/domain/mail/MailReservationRepositoryImpl.kt @@ -15,6 +15,10 @@ class MailReservationRepositoryImpl( return jpaMailReservationRepository.save(MailReservationEntity.from(mailReservation)).toDomain() } + override fun findAll(): List { + return jpaMailReservationRepository.findAll().map { it.toDomain() } + } + override fun findAllByReservationTimeLessThanEqual(reservationTime: Instant): List { return jpaMailReservationRepository.findAllByReservationTimeLessThanEqual(reservationTime).map { it.toDomain() } } @@ -35,11 +39,24 @@ class MailReservationRepositoryImpl( .map { it.toDomain() } } + override fun findAllByReservationTimeLessThanEqualAndSenderEmails( + time: Instant, + senderEmails: List, + ): List { + return jpaMailReservationRepository.findAllByReservationTimeLessThanEqualAndSenderEmails(time, senderEmails) + .map { it.toDomain() } + } + override fun findAllBySenderEmail(senderEmail: String): List { return jpaMailReservationRepository.findAllBySenderEmail(senderEmail) .map { it.toDomain() } } + override fun findAllBySenderEmails(senderEmails: List): List { + return jpaMailReservationRepository.findAllBySenderEmails(senderEmails) + .map { it.toDomain() } + } + override fun findAllBySenderEmailAndReservationTimeBetween( senderEmail: String, from: Instant, @@ -49,6 +66,15 @@ class MailReservationRepositoryImpl( .map { it.toDomain() } } + override fun findAllBySenderEmailsAndReservationTimeBetween( + senderEmails: List, + from: Instant, + to: Instant, + ): List { + return jpaMailReservationRepository.findAllBySenderEmailsAndReservationTimeBetween(senderEmails, from, to) + .map { it.toDomain() } + } + override fun findById(id: Long): MailReservation? { return jpaMailReservationRepository.findById(id).orElse(null)?.toDomain() } diff --git a/src/main/kotlin/com/yourssu/scouter/hrms/business/domain/member/MemberPrivacyService.kt b/src/main/kotlin/com/yourssu/scouter/hrms/business/domain/member/MemberPrivacyService.kt index 77b89178..b4b818e0 100644 --- a/src/main/kotlin/com/yourssu/scouter/hrms/business/domain/member/MemberPrivacyService.kt +++ b/src/main/kotlin/com/yourssu/scouter/hrms/business/domain/member/MemberPrivacyService.kt @@ -35,6 +35,30 @@ class MemberPrivacyService( return member.parts.mapNotNull { it.id }.toSet() } + /** + * 본인과 같은 파트(팀)에 속한 Active 멤버들의 이메일 목록을 반환한다. + * - 사용자가 HRMS 멤버가 아니면(파트 정보를 알 수 없으면) 본인 이메일만 반환한다. + */ + fun getActiveTeamMemberEmails(userId: Long): Set { + val user = userReader.readById(userId) + val myEmail: String = user.getEmailAddress() + val myPartIds: Set = getMemberPartIds(userId) + if (myPartIds.isEmpty()) { + return setOf(myEmail) + } + + val activeMembers = memberReader.readAllActive() + val teamEmails = + activeMembers + .asSequence() + .map { it.member } + .filter { member -> member.parts.any { part -> part.id != null && myPartIds.contains(part.id) } } + .map { it.email } + .toSet() + + return teamEmails + myEmail + } + fun isPrivilegedUser(userId: Long): Boolean { if (devPrivilegeTestHolder?.isMarkedAsNonPrivileged(userId) == true) { return false diff --git a/src/test/kotlin/com/yourssu/scouter/common/business/domain/mail/MailServiceTest.kt b/src/test/kotlin/com/yourssu/scouter/common/business/domain/mail/MailServiceTest.kt index c4a59982..32db6c2e 100644 --- a/src/test/kotlin/com/yourssu/scouter/common/business/domain/mail/MailServiceTest.kt +++ b/src/test/kotlin/com/yourssu/scouter/common/business/domain/mail/MailServiceTest.kt @@ -19,6 +19,7 @@ import com.yourssu.scouter.common.implement.support.exception.MailReservationAcc import com.yourssu.scouter.common.implement.support.exception.MailReservationAlreadyProcessedException import com.yourssu.scouter.common.implement.support.exception.MailReservationNotFoundException import com.yourssu.scouter.common.implement.support.exception.MailReservationNotYetDueException +import com.yourssu.scouter.hrms.business.domain.member.MemberPrivacyService import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThatThrownBy import org.junit.jupiter.api.Test @@ -40,6 +41,7 @@ class MailServiceTest { private val mailRepository = mock() private val oauth2Service = mock() private val mailSender = mock() + private val memberPrivacyService = mock() private fun createService(): MailService { return MailService( @@ -52,6 +54,7 @@ class MailServiceTest { mailRepository = mailRepository, oauth2Service = oauth2Service, mailSender = mailSender, + memberPrivacyService = memberPrivacyService, ) } @@ -86,6 +89,7 @@ class MailServiceTest { val senderEmail = "user@example.com" val user = createUser(userId, senderEmail) whenever(userReader.readById(userId)).thenReturn(user) + whenever(memberPrivacyService.getActiveTeamMemberEmails(userId)).thenReturn(setOf(senderEmail)) val reservation = MailReservation( @@ -93,7 +97,7 @@ class MailServiceTest { mailId = 100L, reservationTime = Instant.parse("2026-03-01T00:00:00Z"), ) - whenever(mailReservationReader.readAllBySenderEmail(senderEmail)).thenReturn(listOf(reservation)) + whenever(mailReservationReader.readAllBySenderEmails(any())).thenReturn(listOf(reservation)) val mail = Mail( @@ -114,6 +118,7 @@ class MailServiceTest { val results = service.getUserMailReservations(userId) // then + verify(mailReservationReader).readAllBySenderEmails(listOf(senderEmail)) assertThat(results).hasSize(1) val detail = results[0] assertThat(detail.reservationId).isEqualTo(10L) @@ -144,6 +149,7 @@ class MailServiceTest { val userId = 1L val user = createUser(userId, "user@example.com") whenever(userReader.readById(userId)).thenReturn(user) + whenever(memberPrivacyService.getActiveTeamMemberEmails(userId)).thenReturn(setOf("user@example.com")) val reservation = MailReservation( @@ -585,4 +591,83 @@ class MailServiceTest { .isInstanceOf(MailFailedException::class.java) .hasMessageContaining("메일 발송에 실패했습니다") } + + @Test + fun `getUserMailReservation는 같은 팀(발신자 이메일이 팀 이메일 목록에 포함)이면 타인의 예약도 조회할 수 있다`() { + // given + val userId = 1L + val user = createUser(userId, "viewer@example.com") + whenever(userReader.readById(userId)).thenReturn(user) + whenever(memberPrivacyService.getActiveTeamMemberEmails(userId)).thenReturn(setOf("viewer@example.com", "teammate@example.com")) + + val reservation = + MailReservation( + id = 10L, + mailId = 100L, + reservationTime = Instant.parse("2026-03-01T00:00:00Z"), + ) + whenever(mailReservationReader.readById(10L)).thenReturn(reservation) + + val mail = + Mail( + id = 100L, + senderEmailAddress = "teammate@example.com", + receiverEmailAddresses = listOf("to@example.com"), + ccEmailAddresses = emptyList(), + bccEmailAddresses = emptyList(), + mailSubject = "제목", + mailBody = "본문", + bodyFormat = MailBodyFormat.HTML, + ) + whenever(mailRepository.findById(100L)).thenReturn(mail) + + val service = createService() + + // when + val detail = service.getUserMailReservation(userId, 10L) + + // then + assertThat(detail.mailId).isEqualTo(100L) + assertThat(detail.senderEmailAddress).isEqualTo("teammate@example.com") + } + + @Test + fun `특권 유저는 예약 목록 조회 시 전체를 조회한다`() { + // given + val userId = 1L + val user = createUser(userId, "privileged@example.com") + whenever(userReader.readById(userId)).thenReturn(user) + whenever(memberPrivacyService.isPrivilegedUser(userId)).thenReturn(true) + + val reservation = + MailReservation( + id = 10L, + mailId = 100L, + reservationTime = Instant.parse("2026-03-01T00:00:00Z"), + ) + whenever(mailReservationReader.readAll()).thenReturn(listOf(reservation)) + + val mail = + Mail( + id = 100L, + senderEmailAddress = "other@example.com", + receiverEmailAddresses = listOf("to@example.com"), + ccEmailAddresses = emptyList(), + bccEmailAddresses = emptyList(), + mailSubject = "제목", + mailBody = "본문", + bodyFormat = MailBodyFormat.HTML, + ) + whenever(mailRepository.findById(100L)).thenReturn(mail) + + val service = createService() + + // when + val results = service.getUserMailReservations(userId) + + // then + verify(mailReservationReader).readAll() + assertThat(results).hasSize(1) + assertThat(results.first().senderEmailAddress).isEqualTo("other@example.com") + } }