diff --git a/src/main/kotlin/apply/application/MailHistoryService.kt b/src/main/kotlin/apply/application/MailHistoryService.kt index 2c77a2592..86df61263 100644 --- a/src/main/kotlin/apply/application/MailHistoryService.kt +++ b/src/main/kotlin/apply/application/MailHistoryService.kt @@ -1,7 +1,6 @@ package apply.application import apply.application.mail.MailData -import apply.domain.mail.MailHistory import apply.domain.mail.MailHistoryRepository import apply.domain.mail.getById import org.springframework.stereotype.Service @@ -12,18 +11,6 @@ import javax.transaction.Transactional class MailHistoryService( private val mailHistoryRepository: MailHistoryRepository ) { - fun save(request: MailData) { - mailHistoryRepository.save( - MailHistory( - request.subject, - request.body, - request.sender, - request.recipients, - request.sentTime - ) - ) - } - fun findAll(): List { return mailHistoryRepository.findAll().map { MailData(it) } } diff --git a/src/main/kotlin/apply/application/mail/MailData.kt b/src/main/kotlin/apply/application/mail/MailData.kt index 63227dc3f..392f38225 100644 --- a/src/main/kotlin/apply/application/mail/MailData.kt +++ b/src/main/kotlin/apply/application/mail/MailData.kt @@ -1,6 +1,7 @@ package apply.application.mail import apply.domain.mail.MailHistory +import org.springframework.core.io.ByteArrayResource import java.time.LocalDateTime import javax.validation.constraints.NotEmpty import javax.validation.constraints.NotNull @@ -24,6 +25,8 @@ data class MailData( @field:NotNull var sentTime: LocalDateTime = LocalDateTime.now(), + var attachments: Map = emptyMap(), + @field:NotNull var id: Long = 0L ) { @@ -33,6 +36,6 @@ data class MailData( mailHistory.sender, mailHistory.recipients, mailHistory.sentTime, - mailHistory.id + id = mailHistory.id ) } diff --git a/src/main/kotlin/apply/application/mail/MailSender.kt b/src/main/kotlin/apply/application/mail/MailSender.kt index 886645543..98d991994 100644 --- a/src/main/kotlin/apply/application/mail/MailSender.kt +++ b/src/main/kotlin/apply/application/mail/MailSender.kt @@ -1,5 +1,9 @@ package apply.application.mail +import org.springframework.core.io.ByteArrayResource + interface MailSender { fun send(toAddress: String, subject: String, body: String) + + fun sendBcc(toAddresses: List, subject: String, body: String, attachments: Map) } diff --git a/src/main/kotlin/apply/application/mail/MailService.kt b/src/main/kotlin/apply/application/mail/MailService.kt index 8a676c55c..058ab3f87 100644 --- a/src/main/kotlin/apply/application/mail/MailService.kt +++ b/src/main/kotlin/apply/application/mail/MailService.kt @@ -2,24 +2,32 @@ package apply.application.mail import apply.application.ApplicationProperties import apply.domain.applicationform.ApplicationFormSubmittedEvent +import apply.domain.mail.MailHistory +import apply.domain.mail.MailHistoryRepository import apply.domain.recruitment.RecruitmentRepository import apply.domain.recruitment.getById import apply.domain.user.PasswordResetEvent import apply.domain.user.UserRepository import apply.domain.user.getById +import org.springframework.boot.autoconfigure.mail.MailProperties +import org.springframework.core.io.ByteArrayResource import org.springframework.scheduling.annotation.Async import org.springframework.stereotype.Service import org.springframework.transaction.event.TransactionalEventListener import org.thymeleaf.context.Context import org.thymeleaf.spring5.ISpringTemplateEngine +private const val MAIL_SENDING_UNIT: Int = 50 + @Service class MailService( private val userRepository: UserRepository, private val recruitmentRepository: RecruitmentRepository, + private val mailHistoryRepository: MailHistoryRepository, private val applicationProperties: ApplicationProperties, private val templateEngine: ISpringTemplateEngine, - private val mailSender: MailSender + private val mailSender: MailSender, + private val mailProperties: MailProperties ) { @Async @TransactionalEventListener @@ -78,4 +86,37 @@ class MailService( templateEngine.process("mail/email-authentication.html", context) ) } + + @Async + fun sendMailsByBcc(request: MailData, files: Map) { + val context = Context().apply { + setVariables( + mapOf( + "content" to request.body, + "url" to applicationProperties.url + ) + ) + } + val body = templateEngine.process("mail/common", context) + val recipients = request.recipients + mailProperties.username + + // TODO: 성공과 실패를 분리하여 히스토리 관리 + val succeeded = mutableListOf() + val failed = mutableListOf() + for (addresses in recipients.chunked(MAIL_SENDING_UNIT)) { + runCatching { mailSender.sendBcc(addresses, request.subject, body, files) } + .onSuccess { succeeded.addAll(addresses) } + .onFailure { failed.addAll(addresses) } + } + + mailHistoryRepository.save( + MailHistory( + request.subject, + request.body, + request.sender, + request.recipients, + request.sentTime + ) + ) + } } diff --git a/src/main/kotlin/apply/infra/mail/AwsMailSender.kt b/src/main/kotlin/apply/infra/mail/AwsMailSender.kt index 3c844ed1a..ce2cbcd63 100644 --- a/src/main/kotlin/apply/infra/mail/AwsMailSender.kt +++ b/src/main/kotlin/apply/infra/mail/AwsMailSender.kt @@ -10,9 +10,26 @@ import com.amazonaws.services.simpleemail.model.Body import com.amazonaws.services.simpleemail.model.Content import com.amazonaws.services.simpleemail.model.Destination import com.amazonaws.services.simpleemail.model.Message +import com.amazonaws.services.simpleemail.model.RawMessage import com.amazonaws.services.simpleemail.model.SendEmailRequest +import com.amazonaws.services.simpleemail.model.SendRawEmailRequest import org.springframework.boot.autoconfigure.mail.MailProperties +import org.springframework.core.io.ByteArrayResource import org.springframework.stereotype.Component +import support.infra.RateLimiter +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +import java.util.Properties +import javax.activation.DataHandler +import javax.activation.DataSource +import javax.activation.MimetypesFileTypeMap +import javax.mail.Message.RecipientType +import javax.mail.Session +import javax.mail.internet.InternetAddress +import javax.mail.internet.MimeBodyPart +import javax.mail.internet.MimeMessage +import javax.mail.internet.MimeMultipart +import javax.mail.util.ByteArrayDataSource @Component class AwsMailSender( @@ -32,7 +49,10 @@ class AwsMailSender( .withRegion(Regions.AP_NORTHEAST_2) .build() + private val rateLimiter = RateLimiter(14) + override fun send(toAddress: String, subject: String, body: String) { + rateLimiter.acquire() val request = SendEmailRequest() .withSource(mailProperties.username) .withDestination(Destination().withToAddresses(toAddress)) @@ -44,7 +64,100 @@ class AwsMailSender( client.sendEmail(request) } + override fun sendBcc( + toAddresses: List, + subject: String, + body: String, + attachments: Map + ) { + rateLimiter.acquire() + val multipartMimeMessage = MultipartMimeMessage( + subject = subject, + userName = mailProperties.username, + recipient = toAddresses, + body = body, + files = attachments + ) + val rawEmailRequest = multipartMimeMessage.getRawEmailRequest() + // TODO: MessageRejectedException, AmazonSimpleEmailServiceException 처리 + client.sendRawEmail(rawEmailRequest) + } + private fun createContent(data: String): Content { - return Content(data).withCharset("UTF-8") + return Content(data).withCharset(Charsets.UTF_8.name()) + } +} + +private class MultipartMimeMessage( + val message: MimeMessage = MimeMessage(Session.getDefaultInstance(Properties())) +) { + constructor( + subject: String, + userName: String, + recipient: List, + body: String, + files: Map + ) : this() { + val mimeMultipart = MimeMultipart("mixed") + setSubject(subject) + setFrom(userName) + setRecipient(recipient) + addBody(body, mimeMultipart) + addAttachment(files, mimeMultipart) + } + + fun setSubject(subject: String) { + message.setSubject(subject, Charsets.UTF_8.name()) + } + + fun setFrom(userName: String) { + message.setFrom(InternetAddress(userName)) + } + + fun setRecipient(recipient: List) { + message.setRecipients( + RecipientType.BCC, + recipient.map { InternetAddress(it) }.toTypedArray() + ) + } + + fun addBody(body: String, mimeMixedPart: MimeMultipart) { + val messageBody = MimeMultipart("alternative") + val wrap = MimeBodyPart() + val htmlPart = MimeBodyPart() + htmlPart.setContent(body, "text/html; charset=UTF-8") + messageBody.addBodyPart(htmlPart) + wrap.setContent(messageBody) + message.setContent(mimeMixedPart) + mimeMixedPart.addBodyPart(wrap) + } + + fun addAttachment(files: Map, mimeMixedPart: MimeMultipart) { + for ((fileName, fileData) in files) { + val bodyPart = MimeBodyPart() + val fds: DataSource = ByteArrayDataSource( + fileData.byteArray, + findMimeContentTypeByFileName(fileName) + ) + bodyPart.dataHandler = DataHandler(fds) + bodyPart.fileName = fileName + mimeMixedPart.addBodyPart(bodyPart) + } + } + + fun findMimeContentTypeByFileName(fileName: String): String { + return MimetypesFileTypeMap().getContentType(fileName) ?: throw IllegalArgumentException("잘못된 확장자입니다.") + } + + fun getRawEmailRequest(): SendRawEmailRequest { + val rawMessage = createRawMessage(message) + return SendRawEmailRequest(rawMessage) + } + + fun createRawMessage(message: MimeMessage): RawMessage { + ByteArrayOutputStream().use { outputStream -> + message.writeTo(outputStream) + return RawMessage(ByteBuffer.wrap(outputStream.toByteArray())) + } } } diff --git a/src/main/kotlin/apply/infra/mail/SimpleMailSender.kt b/src/main/kotlin/apply/infra/mail/SimpleMailSender.kt index 808542b2d..1a1c9f53d 100644 --- a/src/main/kotlin/apply/infra/mail/SimpleMailSender.kt +++ b/src/main/kotlin/apply/infra/mail/SimpleMailSender.kt @@ -2,6 +2,7 @@ package apply.infra.mail import apply.application.mail.MailSender import org.springframework.boot.autoconfigure.mail.MailProperties +import org.springframework.core.io.ByteArrayResource import org.springframework.mail.javamail.JavaMailSender import org.springframework.mail.javamail.MimeMessageHelper @@ -19,4 +20,23 @@ class SimpleMailSender( } mailSender.send(message) } + + override fun sendBcc( + toAddresses: List, + subject: String, + body: String, + attachments: Map + ) { + val message = mailSender.createMimeMessage() + val mimeMessageHelper = MimeMessageHelper(message, true).apply { + setFrom(mailProperties.username) + setBcc(toAddresses.toTypedArray()) + setSubject(subject) + setText(body, true) + } + attachments.forEach { (fileName, data) -> + mimeMessageHelper.addAttachment(fileName, data) + } + mailSender.send(message) + } } diff --git a/src/main/kotlin/apply/ui/admin/evaluation/EvaluationForm.kt b/src/main/kotlin/apply/ui/admin/evaluation/EvaluationForm.kt index 7629199d5..f5520bff5 100644 --- a/src/main/kotlin/apply/ui/admin/evaluation/EvaluationForm.kt +++ b/src/main/kotlin/apply/ui/admin/evaluation/EvaluationForm.kt @@ -89,7 +89,6 @@ class EvaluationForm() : BindingIdentityFormLayout(EvaluationDat result?.beforeEvaluation = beforeEvaluation.value } return result?.apply { - recruitment evaluationItems = items } } diff --git a/src/main/kotlin/apply/ui/admin/mail/MailForm.kt b/src/main/kotlin/apply/ui/admin/mail/MailForm.kt index 402628646..d0d1bb113 100644 --- a/src/main/kotlin/apply/ui/admin/mail/MailForm.kt +++ b/src/main/kotlin/apply/ui/admin/mail/MailForm.kt @@ -17,7 +17,9 @@ import com.vaadin.flow.component.upload.Upload import com.vaadin.flow.component.upload.receivers.MultiFileMemoryBuffer import com.vaadin.flow.data.renderer.ComponentRenderer import com.vaadin.flow.data.renderer.Renderer +import elemental.json.JsonObject import org.springframework.boot.autoconfigure.mail.MailProperties +import org.springframework.core.io.ByteArrayResource import support.views.BindingFormLayout import support.views.NO_NAME import support.views.addSortableColumn @@ -34,20 +36,22 @@ class MailForm( private val mailProperties: MailProperties ) : BindingFormLayout(MailData::class) { private val subject: TextField = TextField("제목").apply { setWidthFull() } + private val sender: TextField = createSender() private val body: TextArea = createBody() private val mailTargets: MutableSet = mutableSetOf() + private val uploadFile: MutableMap = mutableMapOf() private val mailTargetsGrid: Grid = createMailTargetsGrid(mailTargets) private val recipientFilter: Component = createRecipientFilter() - private val fileUpload: Component = createFileUpload() + private val fileUpload: Upload = createFileUpload() init { - add(subject, createSender(), recipientFilter, mailTargetsGrid, body, fileUpload) + add(subject, sender, recipientFilter, mailTargetsGrid, body, fileUpload) setResponsiveSteps(ResponsiveStep("0", 1)) drawRequired() refreshGridFooter() } - private fun createSender(): Component { + private fun createSender(): TextField { return TextField("보낸사람").apply { value = mailProperties.username isReadOnly = true @@ -103,15 +107,17 @@ class MailForm( } private fun createFileUpload(): Upload { - return createUpload("파일첨부", MultiFileMemoryBuffer()) { - // TODO: 추후 업로드 된 파일을 메일로 첨부하는 로직이 추가되어야 함 - // (uploadFiles 같은 필드를 두고 mail을 보내는 기능에 포함시키면 될 것 같음) - // it.files.forEach { fileName -> - // val fileData = it.getFileData(fileName) - // val inputStream = it.getInputStream(fileName) - // val readBytes = inputStream.readBytes() - // } + val upload = createUpload("파일첨부", MultiFileMemoryBuffer()) { + it.files.forEach { fileName -> + val byteArray = it.getInputStream(fileName).readBytes() + uploadFile[fileName] = ByteArrayResource(byteArray) + } } + upload.element.addEventListener("file-remove") { event -> + val eventData: JsonObject = event.eventData + uploadFile.remove(eventData.getString("event.detail.file.name")) + }.addEventData("event.detail.file.name") + return upload } private fun createRemoveButton(): Renderer { @@ -123,8 +129,12 @@ class MailForm( } override fun bindOrNull(): MailData? { + if (mailTargets.isEmpty()) { + return null + } return bindDefaultOrNull()?.apply { recipients = mailTargets.map { it.email }.toList() + attachments = uploadFile } } diff --git a/src/main/kotlin/apply/ui/admin/mail/MailsFormView.kt b/src/main/kotlin/apply/ui/admin/mail/MailsFormView.kt index 161db2de0..d1ac8ccdb 100644 --- a/src/main/kotlin/apply/ui/admin/mail/MailsFormView.kt +++ b/src/main/kotlin/apply/ui/admin/mail/MailsFormView.kt @@ -5,6 +5,7 @@ import apply.application.MailHistoryService import apply.application.MailTargetService import apply.application.RecruitmentService import apply.application.UserService +import apply.application.mail.MailService import apply.ui.admin.BaseLayout import com.vaadin.flow.component.Component import com.vaadin.flow.component.UI @@ -21,6 +22,7 @@ import support.views.EDIT_VALUE import support.views.FORM_URL_PATTERN import support.views.Title import support.views.createContrastButton +import support.views.createNotification import support.views.createPrimaryButton @Route(value = "admin/mails", layout = BaseLayout::class) @@ -30,6 +32,7 @@ class MailsFormView( evaluationService: EvaluationService, mailTargetService: MailTargetService, private val mailHistoryService: MailHistoryService, + private val mailService: MailService, mailProperties: MailProperties ) : VerticalLayout(), HasUrlParameter { private val mailForm: MailForm = MailForm( @@ -63,9 +66,11 @@ class MailsFormView( private fun createSubmitButton(): Button { return createPrimaryButton("보내기") { - mailForm.bindOrNull()?.let { - mailHistoryService.save(it) - // TODO: emailService.메일전송(it, uploadFile) + val result = mailForm.bindOrNull() + if (result == null) { + createNotification("받는사람을 한 명 이상 지정해야 합니다.") + } else { + mailService.sendMailsByBcc(result, result.attachments) UI.getCurrent().navigate(MailsView::class.java) } } diff --git a/src/main/kotlin/apply/ui/api/ExceptionHandler.kt b/src/main/kotlin/apply/ui/api/ExceptionHandler.kt index 22369442e..59210b51c 100644 --- a/src/main/kotlin/apply/ui/api/ExceptionHandler.kt +++ b/src/main/kotlin/apply/ui/api/ExceptionHandler.kt @@ -14,6 +14,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.RestControllerAdvice import org.springframework.web.context.request.WebRequest import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler +import support.infra.ExceededRequestException import javax.persistence.EntityNotFoundException @RestControllerAdvice @@ -84,6 +85,14 @@ class ExceptionHandler : ResponseEntityExceptionHandler() { .body(ApiResponse.error(exception.message)) } + @ExceptionHandler(ExceededRequestException::class) + fun handleExceedRateLimitException(exception: ExceededRequestException): ResponseEntity> { + logger.error("message", exception) + return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS) + .header(HttpHeaders.RETRY_AFTER, "1") + .body(ApiResponse.error(exception.message)) + } + @ExceptionHandler(Exception::class) fun handleGlobalException(exception: Exception): ResponseEntity> { logger.error("message", exception) diff --git a/src/main/kotlin/apply/ui/api/MailHistoryRestController.kt b/src/main/kotlin/apply/ui/api/MailHistoryRestController.kt index 112909875..eabc26ac2 100644 --- a/src/main/kotlin/apply/ui/api/MailHistoryRestController.kt +++ b/src/main/kotlin/apply/ui/api/MailHistoryRestController.kt @@ -7,27 +7,14 @@ import apply.security.LoginUser import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController -import javax.validation.Valid @RestController @RequestMapping("/api/mail-history") class MailHistoryRestController( private val mailHistoryService: MailHistoryService ) { - @PostMapping - fun save( - @RequestBody @Valid request: MailData, - @LoginUser(administrator = true) user: User - ): ResponseEntity { - // todo: 파일 첨부하여 보내는 로직 필요 - mailHistoryService.save(request) - return ResponseEntity.ok().build() - } - @GetMapping("/{mailHistoryId}") fun getById( @PathVariable mailHistoryId: Long, diff --git a/src/main/kotlin/apply/ui/api/MailRestController.kt b/src/main/kotlin/apply/ui/api/MailRestController.kt new file mode 100644 index 000000000..4feefaebe --- /dev/null +++ b/src/main/kotlin/apply/ui/api/MailRestController.kt @@ -0,0 +1,30 @@ +package apply.ui.api + +import apply.application.mail.MailData +import apply.application.mail.MailService +import apply.domain.user.User +import apply.security.LoginUser +import org.springframework.core.io.ByteArrayResource +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestPart +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.multipart.MultipartFile + +@RestController +@RequestMapping("/api/mail") +class MailRestController( + private val mailService: MailService +) { + @PostMapping + fun sendMail( + @RequestPart request: MailData, + @RequestPart files: Array, + @LoginUser(administrator = true) user: User + ): ResponseEntity { + val inputStreamFiles = files.associate { (it.originalFilename!! to ByteArrayResource(it.bytes)) } + mailService.sendMailsByBcc(request, inputStreamFiles) + return ResponseEntity.noContent().build() + } +} diff --git a/src/main/kotlin/support/infra/ExceededRequestException.kt b/src/main/kotlin/support/infra/ExceededRequestException.kt new file mode 100644 index 000000000..ce64a054c --- /dev/null +++ b/src/main/kotlin/support/infra/ExceededRequestException.kt @@ -0,0 +1,3 @@ +package support.infra + +class ExceededRequestException(message: String? = null) : RuntimeException(message) diff --git a/src/main/kotlin/support/infra/RateLimiter.kt b/src/main/kotlin/support/infra/RateLimiter.kt new file mode 100644 index 000000000..da209c3c8 --- /dev/null +++ b/src/main/kotlin/support/infra/RateLimiter.kt @@ -0,0 +1,42 @@ +package support.infra + +import java.util.Queue +import java.util.concurrent.ConcurrentLinkedQueue +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +class RateLimiter( + private val permitsPerSecond: Int, + private val available: Queue = ConcurrentLinkedQueue() +) { + init { + require(permitsPerSecond >= 1) + } + + fun acquire(requestTime: Long = System.currentTimeMillis()) { + if (!tryAcquire(requestTime)) { + throw ExceededRequestException("허용된 요청 수를 초과했습니다.") + } + } + + private fun tryAcquire(requestTime: Long = System.currentTimeMillis()): Boolean { + sync(requestTime) + return if (available.size < permitsPerSecond) { + available.offer(requestTime) + true + } else { + false + } + } + + private fun sync(requestTime: Long) { + while (available.isNotEmpty()) { + val elapsedTime = requestTime - available.peek() + require(elapsedTime >= 0) { "잘못된 요청 시간입니다." } + if (elapsedTime.milliseconds < 1.seconds) { + break + } + available.poll() + } + } +} diff --git a/src/main/kotlin/support/views/Buttons.kt b/src/main/kotlin/support/views/Buttons.kt index 139f6839c..c0f63d119 100644 --- a/src/main/kotlin/support/views/Buttons.kt +++ b/src/main/kotlin/support/views/Buttons.kt @@ -38,7 +38,7 @@ fun createUpload( succeededListener: UploadSucceededListener ): Upload { return Upload(receiver).apply { - maxFileSize = 10_485_760 + maxFileSize = 3_145_728 // spring.servlet.multipart.max-file-size uploadButton = createPrimaryButton(text) { } addSucceededListener { succeededListener(receiver) diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index dafc18352..a72aa807d 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -10,6 +10,9 @@ spring.mail.properties.mail.smtp.starttls.enable=true spring.mail.properties.mail.mime.charset=UTF-8 spring.mail.username=EMAIL +spring.servlet.multipart.max-file-size=3MB +spring.servlet.multipart.max-request-size=6MB + aws.access-key=ACCESS_KEY aws.secret-key=SECRET_KEY diff --git a/src/main/resources/templates/mail/common.html b/src/main/resources/templates/mail/common.html index 952ad610c..759ac3eef 100644 --- a/src/main/resources/templates/mail/common.html +++ b/src/main/resources/templates/mail/common.html @@ -43,37 +43,27 @@ border="0" cellpadding="0" cellspacing="0" - style="color: #333333; padding: 0; font-size: 16px" + style=" + color: #333333; + padding: 0; + font-size: 16px; + margin-left: auto; + margin-right: auto; + " > - - - - - - + diff --git a/src/test/kotlin/apply/MailHistoryFixtures.kt.kt b/src/test/kotlin/apply/MailHistoryFixtures.kt.kt index d58835ab8..fa828d416 100644 --- a/src/test/kotlin/apply/MailHistoryFixtures.kt.kt +++ b/src/test/kotlin/apply/MailHistoryFixtures.kt.kt @@ -29,5 +29,5 @@ fun createMailData( sentTime: LocalDateTime = SENT_TIME, id: Long = 0L ): MailData { - return MailData(subject, body, sender, recipients, sentTime, id) + return MailData(subject, body, sender, recipients, sentTime, id = id) } diff --git a/src/test/kotlin/apply/application/MailHistoryServiceTest.kt b/src/test/kotlin/apply/application/MailHistoryServiceTest.kt index f79a59a82..ee044b143 100644 --- a/src/test/kotlin/apply/application/MailHistoryServiceTest.kt +++ b/src/test/kotlin/apply/application/MailHistoryServiceTest.kt @@ -8,7 +8,6 @@ import io.mockk.impl.annotations.MockK import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertDoesNotThrow import support.test.UnitTest import java.time.LocalDateTime @@ -24,13 +23,6 @@ class MailHistoryServiceTest { mailHistoryService = MailHistoryService(mailHistoryRepository) } - @Test - fun `메일 이력을 저장한다`() { - val mailData = createMailData() - every { mailHistoryRepository.save(any()) } returns createMailHistory() - assertDoesNotThrow { mailHistoryService.save(mailData) } - } - @Test fun `저장된 메일 이력을 모두 조회한다`() { val now = LocalDateTime.now() diff --git a/src/test/kotlin/apply/ui/api/MailHistoryRestControllerTest.kt b/src/test/kotlin/apply/ui/api/MailHistoryRestControllerTest.kt index 1fbd3373d..41829dd88 100644 --- a/src/test/kotlin/apply/ui/api/MailHistoryRestControllerTest.kt +++ b/src/test/kotlin/apply/ui/api/MailHistoryRestControllerTest.kt @@ -3,17 +3,13 @@ package apply.ui.api import apply.application.MailHistoryService import apply.createMailData import com.ninjasquad.springmockk.MockkBean -import io.mockk.Runs import io.mockk.every -import io.mockk.just import org.junit.jupiter.api.Test import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest import org.springframework.context.annotation.ComponentScan import org.springframework.context.annotation.FilterType import org.springframework.http.HttpHeaders -import org.springframework.http.MediaType import org.springframework.test.web.servlet.get -import org.springframework.test.web.servlet.post @WebMvcTest( controllers = [MailHistoryRestController::class], @@ -25,19 +21,6 @@ class MailHistoryRestControllerTest : RestControllerTest() { @MockkBean private lateinit var mailHistoryService: MailHistoryService - @Test - fun `이메일 이력을 저장한다`() { - every { mailHistoryService.save(any()) } just Runs - - mockMvc.post("/api/mail-history") { - header(HttpHeaders.AUTHORIZATION, "Bearer valid_token") - contentType = MediaType.APPLICATION_JSON - content = objectMapper.writeValueAsString(createMailData()) - }.andExpect { - status { isOk } - } - } - @Test fun `이메일 내역을 단일 조회한다`() { val mailData = createMailData() diff --git a/src/test/kotlin/support/infra/RateLimiterTest.kt b/src/test/kotlin/support/infra/RateLimiterTest.kt new file mode 100644 index 000000000..da2091a02 --- /dev/null +++ b/src/test/kotlin/support/infra/RateLimiterTest.kt @@ -0,0 +1,66 @@ +package support.infra + +import io.kotest.assertions.assertSoftly +import io.kotest.assertions.throwables.shouldNotThrowAny +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.inspectors.forAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlin.time.Duration.Companion.milliseconds + +private val Int.ms: Long get() = milliseconds.inWholeMilliseconds + +class RateLimiterTest : StringSpec({ + "초당 1회로 제한할 경우 1초 간격 요청은 성공한다" { + val limiter = RateLimiter(1) + listOf(0.ms, 1000.ms, 2000.ms).forAll { + shouldNotThrowAny { limiter.acquire(it) } + } + } + + "초당 1회로 제한할 경우 초당 2번 요청하면 실패한다" { + val limiter = RateLimiter(1).apply { + acquire(0.ms) + } + listOf(100.ms, 500.ms, 900.ms).forAll { + shouldThrow { limiter.acquire(it) } + } + } + + "초당 2회로 제한할 경우 초당 2회 요청은 성공한다" { + val limiter = RateLimiter(2) + listOf(0.ms, 500.ms, 1000.ms, 1500.ms, 2000.ms).forAll { + shouldNotThrowAny { limiter.acquire(it) } + } + } + + "초당 2회로 제한할 경우 초당 3번 요청하면 실패한다" { + val limiter = RateLimiter(2).apply { + acquire(500.ms) + acquire(600.ms) + } + assertSoftly(limiter) { + shouldThrow { acquire(1400.ms) } + shouldNotThrowAny { acquire(1500.ms) } + } + } + + "요청은 순서대로 이루어져야 한다" { + val limiter = RateLimiter(2).apply { + acquire(1000.ms) + } + shouldThrow { limiter.acquire(0.ms) } + } + + "경쟁 상태에서도 순서를 유지한다" { + runTest { + val limiter = RateLimiter(1000) + launch { + repeat(1000) { + shouldNotThrowAny { limiter.acquire() } + } + }.join() + } + } +})