diff --git a/.github/workflows/mealkitary-main-cd.yml b/.github/workflows/mealkitary-main-cd.yml index ba8a2e3..13e8814 100644 --- a/.github/workflows/mealkitary-main-cd.yml +++ b/.github/workflows/mealkitary-main-cd.yml @@ -24,6 +24,9 @@ jobs: echo "::set-output name=DEPLOYMENT::false" fi - uses: actions/checkout@v3 + - name: Firebase 시크릿 생성 + run: | + echo ${{ secrets.ENCODED_FIREBASE_JSON }} | base64 -d > ./mealkitary-infrastructure/adapter-firebase-notification/src/main/resources/firebase.json - name: JDK 11 구성 uses: actions/setup-java@v3 with: diff --git a/.github/workflows/mealkitary-main-develop-ci.yml b/.github/workflows/mealkitary-main-develop-ci.yml index 914ca7e..097fc00 100644 --- a/.github/workflows/mealkitary-main-develop-ci.yml +++ b/.github/workflows/mealkitary-main-develop-ci.yml @@ -17,6 +17,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 + - name: Firebase 시크릿 생성 + run: | + echo ${{ secrets.ENCODED_FIREBASE_JSON }} | base64 -d > ./mealkitary-infrastructure/adapter-firebase-notification/src/main/resources/firebase.json - name: JDK 11 구성 uses: actions/setup-java@v3 with: @@ -50,6 +53,7 @@ jobs: ./mealkitary-application/build/test-results/**/*.xml ./mealkitary-infrastructure/adapter-persistence-spring-data-jpa/build/test-results/**/*.xml ./mealkitary-infrastructure/adapter-paymentgateway-tosspayments/build/test-results/**/*.xml + ./mealkitary-infrastructure/adapter-firebase-notification/build/test-results/**/*.xml - name: Jacoco Coverage 리포트 전송 uses: codecov/codecov-action@v3 @@ -60,7 +64,8 @@ jobs: ./mealkitary-domain/build/reports/jacoco/test/jacocoTestReport.xml, ./mealkitary-application/build/reports/jacoco/test/jacocoTestReport.xml, ./mealkitary-infrastructure/adapter-persistence-spring-data-jpa/build/reports/jacoco/test/jacocoTestReport.xml, - ./mealkitary-infrastructure/adapter-paymentgateway-tosspayments/build/reports/jacoco/test/jacocoTestReport.xml + ./mealkitary-infrastructure/adapter-paymentgateway-tosspayments/build/reports/jacoco/test/jacocoTestReport.xml, + ./mealkitary-infrastructure/adapter-firebase-notification/build/reports/jacoco/test/jacocoTestReport.xml name: mealkitary-codecov verbose: true diff --git a/.github/workflows/mealkitary-test-coverage-automation.yml b/.github/workflows/mealkitary-test-coverage-automation.yml index ff45958..ac958ef 100644 --- a/.github/workflows/mealkitary-test-coverage-automation.yml +++ b/.github/workflows/mealkitary-test-coverage-automation.yml @@ -16,6 +16,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 + - name: Firebase 시크릿 생성 + run: | + echo ${{ secrets.ENCODED_FIREBASE_JSON }} | base64 -d > ./mealkitary-infrastructure/adapter-firebase-notification/src/main/resources/firebase.json - name: JDK 11 구성 uses: actions/setup-java@v3 with: @@ -38,6 +41,7 @@ jobs: ./mealkitary-application/build/test-results/**/*.xml ./mealkitary-infrastructure/adapter-persistence-spring-data-jpa/build/test-results/**/*.xml ./mealkitary-infrastructure/adapter-paymentgateway-tosspayments/build/test-results/**/*.xml + ./mealkitary-infrastructure/adapter-firebase-notification/build/test-results/**/*.xml - name: Jacoco Coverage 리포트 전송 uses: codecov/codecov-action@v3 @@ -48,6 +52,7 @@ jobs: ./mealkitary-domain/build/reports/jacoco/test/jacocoTestReport.xml, ./mealkitary-application/build/reports/jacoco/test/jacocoTestReport.xml, ./mealkitary-infrastructure/adapter-persistence-spring-data-jpa/build/reports/jacoco/test/jacocoTestReport.xml, - ./mealkitary-infrastructure/adapter-paymentgateway-tosspayments/build/reports/jacoco/test/jacocoTestReport.xml + ./mealkitary-infrastructure/adapter-paymentgateway-tosspayments/build/reports/jacoco/test/jacocoTestReport.xml, + ./mealkitary-infrastructure/adapter-firebase-notification/build/reports/jacoco/test/jacocoTestReport.xml name: mealkitary-codecov verbose: true diff --git a/.gitignore b/.gitignore index 48a1d19..3cb35f2 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,6 @@ out/ ## Spring REST docs ## **/src/main/resources/static/docs/ + +## Firebase ## +**/firebase.json diff --git a/build.gradle.kts b/build.gradle.kts index 1cdb57e..62adee3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -74,7 +74,9 @@ subprojects { "**/exception/*", "**/utils/*", "**/*Config*", - "**/*BaseEntity*" + "**/*BaseEntity*", + "**/Firebase*Initializer*", + "**/Firebase*Client*", ) } ) @@ -101,7 +103,9 @@ subprojects { "**.exception.*", "**.utils.*", "**.*Config*", - "**.*BaseEntity*" + "**.*BaseEntity*", + "**.Firebase*Initializer*", + "**.Firebase*Client*", ) } } diff --git a/gradle.properties b/gradle.properties index 6392f4a..af130a6 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,7 +6,7 @@ ktlintVersion=11.0.0 springBootVersion=2.7.11 springDependencyManagementVersion=1.0.15.RELEASE # project -applicationVersion=0.1.1 +applicationVersion=0.2.0 projectGroup=com.mealkitary # test kotestVersion=4.4.3 @@ -25,3 +25,5 @@ asciidoctorVersion=3.3.2 jibVersion=3.1.4 # ulid ulidCreatorVersion=5.2.0 +# firebase +firebaseAdminVersion=9.1.1 diff --git a/mealkitary-api/build.gradle.kts b/mealkitary-api/build.gradle.kts index 9453bd6..a6459f9 100644 --- a/mealkitary-api/build.gradle.kts +++ b/mealkitary-api/build.gradle.kts @@ -20,6 +20,7 @@ dependencies { implementation(project(":mealkitary-domain")) implementation(project(":mealkitary-infrastructure:adapter-persistence-spring-data-jpa")) implementation(project(":mealkitary-infrastructure:adapter-paymentgateway-tosspayments")) + implementation(project(":mealkitary-infrastructure:adapter-firebase-notification")) testImplementation("org.springframework.restdocs:spring-restdocs-mockmvc") asciidoctorExt("org.springframework.restdocs:spring-restdocs-asciidoctor") } diff --git a/mealkitary-api/src/docs/asciidoc/reservation.adoc b/mealkitary-api/src/docs/asciidoc/reservation.adoc index 3c684fa..3fa8005 100644 --- a/mealkitary-api/src/docs/asciidoc/reservation.adoc +++ b/mealkitary-api/src/docs/asciidoc/reservation.adoc @@ -49,3 +49,17 @@ include::{snippets}/reservation-post-pay/http-response.adoc[] ===== 응답 헤더 include::{snippets}/reservation-post-pay/response-headers.adoc[] + +==== 예약 승인 + +결제된 예약에 대해서 예약 승인 처리합니다. 결제된 예약이 아닌 경우, 승인 처리할 수 없습니다. + +===== 요청 + +include::{snippets}/reservation-post-accept/curl-request.adoc[] +include::{snippets}/reservation-post-accept/http-request.adoc[] +include::{snippets}/reservation-post-accept/path-parameters.adoc[] + +===== 응답 + +include::{snippets}/reservation-post-accept/http-response.adoc[] diff --git a/mealkitary-api/src/main/kotlin/com/mealkitary/reservation/adapter/input/web/AcceptReservationController.kt b/mealkitary-api/src/main/kotlin/com/mealkitary/reservation/adapter/input/web/AcceptReservationController.kt new file mode 100644 index 0000000..67eba74 --- /dev/null +++ b/mealkitary-api/src/main/kotlin/com/mealkitary/reservation/adapter/input/web/AcceptReservationController.kt @@ -0,0 +1,23 @@ +package com.mealkitary.reservation.adapter.input.web + +import com.mealkitary.common.utils.UUIDUtils +import com.mealkitary.reservation.application.port.input.AcceptReservationUseCase +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/reservations") +class AcceptReservationController( + private val acceptReservationUseCase: AcceptReservationUseCase +) { + + @PostMapping("/{reservationId}/accept") + fun acceptReservation(@PathVariable("reservationId") reservationId: String): ResponseEntity { + acceptReservationUseCase.accept(UUIDUtils.fromString(reservationId)) + + return ResponseEntity.noContent().build() + } +} diff --git a/mealkitary-api/src/test/kotlin/com/docs/reservation/AcceptReservationControllerDocsTest.kt b/mealkitary-api/src/test/kotlin/com/docs/reservation/AcceptReservationControllerDocsTest.kt new file mode 100644 index 0000000..a65ae4d --- /dev/null +++ b/mealkitary-api/src/test/kotlin/com/docs/reservation/AcceptReservationControllerDocsTest.kt @@ -0,0 +1,44 @@ +package com.docs.reservation + +import com.docs.RestDocsSupport +import com.mealkitary.reservation.adapter.input.web.AcceptReservationController +import com.mealkitary.reservation.application.port.input.AcceptReservationUseCase +import io.mockk.every +import io.mockk.mockk +import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders +import org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest +import org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse +import org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint +import org.springframework.restdocs.request.RequestDocumentation.parameterWithName +import org.springframework.restdocs.request.RequestDocumentation.pathParameters +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import java.util.UUID + +class AcceptReservationControllerDocsTest : RestDocsSupport() { + + private val acceptReservationUseCase = mockk() + + @Test + fun `api docs test - acceptReservation`() { + val id = UUID.randomUUID() + every { acceptReservationUseCase.accept(any()) }.answers { } + + mvc.perform( + RestDocumentationRequestBuilders.post("/reservations/{reservationId}/accept", id) + ) + .andExpect(status().isNoContent) + .andDo( + document( + "reservation-post-accept", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("reservationId").description("승인 대상 예약의 식별자") + ), + ) + ) + } + + override fun initController() = AcceptReservationController(acceptReservationUseCase) +} diff --git a/mealkitary-api/src/test/kotlin/com/mealkitary/WebIntegrationTestSupport.kt b/mealkitary-api/src/test/kotlin/com/mealkitary/WebIntegrationTestSupport.kt index f45e344..e552f5e 100644 --- a/mealkitary-api/src/test/kotlin/com/mealkitary/WebIntegrationTestSupport.kt +++ b/mealkitary-api/src/test/kotlin/com/mealkitary/WebIntegrationTestSupport.kt @@ -1,8 +1,10 @@ package com.mealkitary import com.fasterxml.jackson.databind.ObjectMapper +import com.mealkitary.reservation.adapter.input.web.AcceptReservationController import com.mealkitary.reservation.adapter.input.web.PayReservationController import com.mealkitary.reservation.adapter.input.web.ReserveProductController +import com.mealkitary.reservation.application.port.input.AcceptReservationUseCase import com.mealkitary.reservation.application.port.input.PayReservationUseCase import com.mealkitary.reservation.application.port.input.ReserveProductUseCase import com.mealkitary.shop.adapter.input.web.GetProductController @@ -22,6 +24,7 @@ import org.springframework.test.web.servlet.MockMvc controllers = [ ReserveProductController::class, PayReservationController::class, + AcceptReservationController::class, GetShopController::class, GetReservableTimeController::class, GetProductController::class @@ -43,6 +46,9 @@ abstract class WebIntegrationTestSupport : AnnotationSpec() { @MockkBean protected lateinit var payReservationUseCase: PayReservationUseCase + @MockkBean + protected lateinit var acceptReservationUseCase: AcceptReservationUseCase + @MockkBean protected lateinit var getShopQuery: GetShopQuery diff --git a/mealkitary-api/src/test/kotlin/com/mealkitary/reservation/adapter/input/web/AcceptReservationControllerTest.kt b/mealkitary-api/src/test/kotlin/com/mealkitary/reservation/adapter/input/web/AcceptReservationControllerTest.kt new file mode 100644 index 0000000..c1c930a --- /dev/null +++ b/mealkitary-api/src/test/kotlin/com/mealkitary/reservation/adapter/input/web/AcceptReservationControllerTest.kt @@ -0,0 +1,45 @@ +package com.mealkitary.reservation.adapter.input.web + +import com.mealkitary.WebIntegrationTestSupport +import com.mealkitary.common.exception.EntityNotFoundException +import io.mockk.every +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders +import org.springframework.test.web.servlet.result.MockMvcResultMatchers +import java.util.UUID + +class AcceptReservationControllerTest : WebIntegrationTestSupport() { + + @Test + fun `api integration test - acceptReservation`() { + val id = UUID.randomUUID() + every { acceptReservationUseCase.accept(any()) } answers {} + + mvc.perform( + MockMvcRequestBuilders.post("/reservations/{reservationId}/accept", id.toString()) + ) + .andExpect(MockMvcResultMatchers.status().isNoContent) + } + + @Test + fun `api integration test - 예약 식별자가 UUID 형태가 아니라면 400 에러를 발생한다`() { + mvc.perform( + MockMvcRequestBuilders.post("/reservations/{reservationId}/accept", "invalid-uuid-test") + ) + .andExpect(MockMvcResultMatchers.status().isBadRequest) + .andExpect(MockMvcResultMatchers.jsonPath("$.status").value("400")) + .andExpect(MockMvcResultMatchers.jsonPath("$.message").value("잘못된 UUID 형식입니다.")) + } + + @Test + fun `api integration test - 내부에서 EntityNotFound 에러가 발생하면 404 에러를 발생한다`() { + val id = UUID.randomUUID() + every { acceptReservationUseCase.accept(any()) }.throws(EntityNotFoundException("존재하지 않는 예약입니다.")) + + mvc.perform( + MockMvcRequestBuilders.post("/reservations/{reservationId}/accept", id.toString()) + ) + .andExpect(MockMvcResultMatchers.status().isNotFound) + .andExpect(MockMvcResultMatchers.jsonPath("$.status").value("404")) + .andExpect(MockMvcResultMatchers.jsonPath("$.message").value("존재하지 않는 예약입니다.")) + } +} diff --git a/mealkitary-application/src/main/kotlin/com/mealkitary/reservation/application/port/input/AcceptReservationUseCase.kt b/mealkitary-application/src/main/kotlin/com/mealkitary/reservation/application/port/input/AcceptReservationUseCase.kt new file mode 100644 index 0000000..75d8a17 --- /dev/null +++ b/mealkitary-application/src/main/kotlin/com/mealkitary/reservation/application/port/input/AcceptReservationUseCase.kt @@ -0,0 +1,8 @@ +package com.mealkitary.reservation.application.port.input + +import java.util.UUID + +interface AcceptReservationUseCase { + + fun accept(reservationId: UUID) +} diff --git a/mealkitary-application/src/main/kotlin/com/mealkitary/reservation/application/port/output/SendAcceptedReservationMessagePort.kt b/mealkitary-application/src/main/kotlin/com/mealkitary/reservation/application/port/output/SendAcceptedReservationMessagePort.kt new file mode 100644 index 0000000..5a3b035 --- /dev/null +++ b/mealkitary-application/src/main/kotlin/com/mealkitary/reservation/application/port/output/SendAcceptedReservationMessagePort.kt @@ -0,0 +1,6 @@ +package com.mealkitary.reservation.application.port.output + +interface SendAcceptedReservationMessagePort { + + fun sendAcceptedReservationMessage() +} diff --git a/mealkitary-application/src/main/kotlin/com/mealkitary/reservation/application/port/output/SendNewReservationMessagePort.kt b/mealkitary-application/src/main/kotlin/com/mealkitary/reservation/application/port/output/SendNewReservationMessagePort.kt new file mode 100644 index 0000000..8e17b32 --- /dev/null +++ b/mealkitary-application/src/main/kotlin/com/mealkitary/reservation/application/port/output/SendNewReservationMessagePort.kt @@ -0,0 +1,8 @@ +package com.mealkitary.reservation.application.port.output + +import java.util.UUID + +interface SendNewReservationMessagePort { + + fun sendNewReservationMessage(reservationId: UUID, description: String) +} diff --git a/mealkitary-application/src/main/kotlin/com/mealkitary/reservation/application/service/AcceptReservationService.kt b/mealkitary-application/src/main/kotlin/com/mealkitary/reservation/application/service/AcceptReservationService.kt new file mode 100644 index 0000000..6bcf320 --- /dev/null +++ b/mealkitary-application/src/main/kotlin/com/mealkitary/reservation/application/service/AcceptReservationService.kt @@ -0,0 +1,25 @@ +package com.mealkitary.reservation.application.service + +import com.mealkitary.reservation.application.port.input.AcceptReservationUseCase +import com.mealkitary.reservation.application.port.output.LoadReservationPort +import com.mealkitary.reservation.application.port.output.SendAcceptedReservationMessagePort +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +@Service +@Transactional(readOnly = true) +class AcceptReservationService( + private val loadReservationPort: LoadReservationPort, + private val sendAcceptedReservationMessagePort: SendAcceptedReservationMessagePort +) : AcceptReservationUseCase { + + @Transactional + override fun accept(reservationId: UUID) { + val reservation = loadReservationPort.loadOneReservationById(reservationId) + + reservation.accept() + + sendAcceptedReservationMessagePort.sendAcceptedReservationMessage() + } +} diff --git a/mealkitary-application/src/main/kotlin/com/mealkitary/reservation/application/service/PayReservationService.kt b/mealkitary-application/src/main/kotlin/com/mealkitary/reservation/application/service/PayReservationService.kt index a4b8929..03b0b46 100644 --- a/mealkitary-application/src/main/kotlin/com/mealkitary/reservation/application/service/PayReservationService.kt +++ b/mealkitary-application/src/main/kotlin/com/mealkitary/reservation/application/service/PayReservationService.kt @@ -5,9 +5,11 @@ import com.mealkitary.reservation.application.port.input.PayReservationRequest import com.mealkitary.reservation.application.port.input.PayReservationUseCase import com.mealkitary.reservation.application.port.output.LoadReservationPort import com.mealkitary.reservation.application.port.output.SavePaymentPort +import com.mealkitary.reservation.application.port.output.SendNewReservationMessagePort import com.mealkitary.reservation.domain.payment.ConfirmPaymentService import com.mealkitary.reservation.domain.payment.Payment import com.mealkitary.reservation.domain.payment.PaymentGatewayService +import com.mealkitary.reservation.domain.reservation.Reservation import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.util.UUID @@ -17,6 +19,7 @@ import java.util.UUID class PayReservationService( private val loadReservationPort: LoadReservationPort, private val savePaymentPort: SavePaymentPort, + private val sendNewReservationMessagePort: SendNewReservationMessagePort, paymentGatewayService: PaymentGatewayService ) : PayReservationUseCase { @@ -26,14 +29,19 @@ class PayReservationService( @Transactional override fun pay(payReservationRequest: PayReservationRequest): UUID { val reservation = loadReservationPort.loadOneReservationById(payReservationRequest.reservationId) - val payment = Payment.of( - payReservationRequest.paymentKey, - reservation, - Money.from(payReservationRequest.amount) - ) + val payment = createPayment(payReservationRequest, reservation) confirmPaymentService.confirm(payment) + sendNewReservationMessage(reservation) + return savePaymentPort.saveOne(payment) } + + private fun createPayment(payReservationRequest: PayReservationRequest, reservation: Reservation) = + Payment.of(payReservationRequest.paymentKey, reservation, Money.from(payReservationRequest.amount)) + + private fun sendNewReservationMessage(reservation: Reservation) { + sendNewReservationMessagePort.sendNewReservationMessage(reservation.id, reservation.buildDescription()) + } } diff --git a/mealkitary-application/src/test/kotlin/com/mealkitary/reservation/application/service/AcceptReservationServiceTest.kt b/mealkitary-application/src/test/kotlin/com/mealkitary/reservation/application/service/AcceptReservationServiceTest.kt new file mode 100644 index 0000000..9643c0c --- /dev/null +++ b/mealkitary-application/src/test/kotlin/com/mealkitary/reservation/application/service/AcceptReservationServiceTest.kt @@ -0,0 +1,100 @@ +package com.mealkitary.reservation.application.service + +import com.mealkitary.reservation.application.port.output.LoadReservationPort +import com.mealkitary.reservation.application.port.output.SendAcceptedReservationMessagePort +import com.mealkitary.reservation.domain.reservation.Reservation +import com.mealkitary.reservation.domain.reservation.ReservationStatus +import data.ReservationTestData +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.AnnotationSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.throwable.shouldHaveMessage +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify + +class AcceptReservationServiceTest : AnnotationSpec() { + + private val loadReservationPort = mockk() + private val sendAcceptedReservationMessagePort = mockk() + + private val acceptReservationService = + AcceptReservationService(loadReservationPort, sendAcceptedReservationMessagePort) + + @Test + fun `service unit test - 예약이 정상적으로 승인되면 예약의 상태가 승인됨으로 변경된다`() { + val reservation = ReservationTestData.defaultReservation() + .withReservationStatus(ReservationStatus.PAID) + .build() + stubbingReservation(reservation) + + acceptReservationService.accept(reservation.id) + + reservation.reservationStatus shouldBe ReservationStatus.RESERVED + } + + @Test + fun `service unit test - 예약이 정상적으로 승인되면 예약 승인 알림이 전송된다`() { + val reservation = ReservationTestData.defaultReservation() + .withReservationStatus(ReservationStatus.PAID) + .build() + stubbingReservation(reservation) + + acceptReservationService.accept(reservation.id) + + verify(exactly = 1) { sendAcceptedReservationMessagePort.sendAcceptedReservationMessage() } + } + + @Test + fun `service unit test - 미결제 상태의 예약을 승인하면 예외를 발생한다`() { + val reservation = ReservationTestData.defaultReservation() + .withReservationStatus(ReservationStatus.NOTPAID) + .build() + stubbingReservation(reservation) + + shouldThrow { + acceptReservationService.accept(reservation.id) + } shouldHaveMessage "미결제 상태인 예약은 승인할 수 없습니다." + } + + @Test + fun `service unit test - 정상적으로 생성되지 않은 예약을 승인하면 예외를 발생한다`() { + val reservation = ReservationTestData.defaultReservation() + .withReservationStatus(ReservationStatus.NONE) + .build() + stubbingReservation(reservation) + + shouldThrow { + acceptReservationService.accept(reservation.id) + } shouldHaveMessage "정상적으로 생성된 예약에 대해서만 수행 가능합니다." + } + + @Test + fun `service unit test - 이미 승인된 예약은 다시 승인하면 예외를 발생한다`() { + val reservation = ReservationTestData.defaultReservation() + .withReservationStatus(ReservationStatus.RESERVED) + .build() + stubbingReservation(reservation) + + shouldThrow { + acceptReservationService.accept(reservation.id) + } shouldHaveMessage "이미 승인된 예약입니다." + } + + @Test + fun `service unit test - 이미 거절된 예약을 승인하면 예외를 발생한다`() { + val reservation = ReservationTestData.defaultReservation() + .withReservationStatus(ReservationStatus.REJECTED) + .build() + stubbingReservation(reservation) + + shouldThrow { + acceptReservationService.accept(reservation.id) + } shouldHaveMessage "이미 예약 거부된 건에 대해서 승인할 수 없습니다." + } + + private fun stubbingReservation(reservation: Reservation) { + every { loadReservationPort.loadOneReservationById(any()) } answers { reservation } + every { sendAcceptedReservationMessagePort.sendAcceptedReservationMessage() } answers {} + } +} diff --git a/mealkitary-application/src/test/kotlin/com/mealkitary/reservation/application/service/PayReservationServiceTest.kt b/mealkitary-application/src/test/kotlin/com/mealkitary/reservation/application/service/PayReservationServiceTest.kt index 4003f7e..ae11ae0 100644 --- a/mealkitary-application/src/test/kotlin/com/mealkitary/reservation/application/service/PayReservationServiceTest.kt +++ b/mealkitary-application/src/test/kotlin/com/mealkitary/reservation/application/service/PayReservationServiceTest.kt @@ -3,6 +3,7 @@ package com.mealkitary.reservation.application.service import com.mealkitary.reservation.application.port.input.PayReservationRequest import com.mealkitary.reservation.application.port.output.LoadReservationPort import com.mealkitary.reservation.application.port.output.SavePaymentPort +import com.mealkitary.reservation.application.port.output.SendNewReservationMessagePort import com.mealkitary.reservation.domain.payment.PaymentGatewayService import com.mealkitary.reservation.domain.reservation.Reservation import com.mealkitary.reservation.domain.reservation.ReservationStatus @@ -13,15 +14,23 @@ import io.kotest.matchers.shouldBe import io.kotest.matchers.throwable.shouldHaveMessage import io.mockk.every import io.mockk.mockk +import io.mockk.slot +import java.util.UUID class PayReservationServiceTest : AnnotationSpec() { private val loadReservationPort = mockk() private val savePaymentPort = mockk() private val paymentGatewayService = mockk() + private val sendNewReservationMessagePort = mockk() private val payReservationService = - PayReservationService(loadReservationPort, savePaymentPort, paymentGatewayService) + PayReservationService( + loadReservationPort, + savePaymentPort, + sendNewReservationMessagePort, + paymentGatewayService + ) @Test fun `service unit test - 신규 결제를 생성한다`() { @@ -57,6 +66,35 @@ class PayReservationServiceTest : AnnotationSpec() { reservation.reservationStatus shouldBe ReservationStatus.PAID } + @Test + fun `service unit test - 결제가 정상적으로 이루어지면 신규 예약 알림이 전송된다`() { + val reservation = ReservationTestData.defaultReservation() + .withReservationStatus(ReservationStatus.NOTPAID) + .build() + val payReservationRequest = PayReservationRequest( + "paymentKey", + reservation.id, + 2000 + ) + stubbingReservation(reservation) + val uuidSlot = slot() + val descriptionSlot = slot() + every { + sendNewReservationMessagePort.sendNewReservationMessage( + capture(uuidSlot), + capture(descriptionSlot) + ) + } answers {} + + payReservationService.pay(payReservationRequest) + + val uuid = uuidSlot.captured + val description = descriptionSlot.captured + + uuid shouldBe reservation.id + description shouldBe "부대찌개 외 1건" + } + @Test fun `service unit test - 예약이 미결제 상태가 아니라면 예외를 발생한다`() { val reservation = ReservationTestData.defaultReservation() @@ -112,5 +150,6 @@ class PayReservationServiceTest : AnnotationSpec() { every { loadReservationPort.loadOneReservationById(any()) } answers { reservation } every { paymentGatewayService.confirm(any()) } answers { } every { savePaymentPort.saveOne(any()) } answers { reservation.id } + every { sendNewReservationMessagePort.sendNewReservationMessage(any(), any()) } answers {} } } diff --git a/mealkitary-domain/src/main/kotlin/com/mealkitary/common/constants/ReservationConstants.kt b/mealkitary-domain/src/main/kotlin/com/mealkitary/common/constants/ReservationConstants.kt index a35198c..22e28d2 100644 --- a/mealkitary-domain/src/main/kotlin/com/mealkitary/common/constants/ReservationConstants.kt +++ b/mealkitary-domain/src/main/kotlin/com/mealkitary/common/constants/ReservationConstants.kt @@ -7,6 +7,8 @@ internal object ReservationConstants { INVALID_RESERVE_TIME("예약 대상 가게는 해당 시간에 예약을 받지 않습니다."), NOTPAID_RESERVATION_CANNOT_ACCEPT("미결제 상태인 예약은 승인할 수 없습니다."), NOTPAID_RESERVATION_CANNOT_REJECT("미결제 상태인 예약은 거부할 수 없습니다."), + ALREADY_ACCEPTED_RESERVATION("이미 승인된 예약입니다."), + ALREADY_REJECTED_RESERVATION("이미 거절된 예약입니다."), ALREADY_REJECTED_RESERVATION_CANNOT_ACCEPT("이미 예약 거부된 건에 대해서 승인할 수 없습니다."), ALREADY_RESERVED_RESERVATION_CANNOT_REJECT("이미 예약 확정된 건에 대해서 거부할 수 없습니다."), INVALID_RESERVATION_STATUS_FOR_PAYMENT("미결제인 상태에서만 이용 가능한 기능입니다."), diff --git a/mealkitary-domain/src/main/kotlin/com/mealkitary/reservation/domain/reservation/Reservation.kt b/mealkitary-domain/src/main/kotlin/com/mealkitary/reservation/domain/reservation/Reservation.kt index 0cc8eda..5188199 100644 --- a/mealkitary-domain/src/main/kotlin/com/mealkitary/reservation/domain/reservation/Reservation.kt +++ b/mealkitary-domain/src/main/kotlin/com/mealkitary/reservation/domain/reservation/Reservation.kt @@ -1,6 +1,8 @@ package com.mealkitary.reservation.domain.reservation +import com.mealkitary.common.constants.ReservationConstants.Validation.ErrorMessage.ALREADY_ACCEPTED_RESERVATION import com.mealkitary.common.constants.ReservationConstants.Validation.ErrorMessage.ALREADY_PROCESSED_RESERVATION +import com.mealkitary.common.constants.ReservationConstants.Validation.ErrorMessage.ALREADY_REJECTED_RESERVATION import com.mealkitary.common.constants.ReservationConstants.Validation.ErrorMessage.ALREADY_REJECTED_RESERVATION_CANNOT_ACCEPT import com.mealkitary.common.constants.ReservationConstants.Validation.ErrorMessage.ALREADY_RESERVED_RESERVATION_CANNOT_REJECT import com.mealkitary.common.constants.ReservationConstants.Validation.ErrorMessage.AT_LEAST_ONE_ITEM_REQUIRED @@ -36,8 +38,7 @@ class Reservation private constructor( @ElementCollection @CollectionTable( - name = "reservation_line_item", - joinColumns = [JoinColumn(name = "reservation_id")] + name = "reservation_line_item", joinColumns = [JoinColumn(name = "reservation_id")] ) private val lineItems: MutableList = lineItems @@ -56,8 +57,7 @@ class Reservation private constructor( fun calculateTotalPrice(): Money { checkNotPaid() - return lineItems.map { it.calculateEachItemTotalPrice() } - .reduce { acc, v -> acc + v } + return lineItems.map { it.calculateEachItemTotalPrice() }.reduce { acc, v -> acc + v } } fun reserve() { @@ -82,14 +82,14 @@ class Reservation private constructor( } private fun checkEachItem() { - lineItems.map { it.mapToProduct() } - .forEach(shop::checkItem) + lineItems.map { it.mapToProduct() }.forEach(shop::checkItem) } fun accept() { checkNone() checkPaidReservation(NOTPAID_RESERVATION_CANNOT_ACCEPT.message) checkAlreadyRejectedForAccept() + checkAlreadyAccepted() changeReservationStatus(ReservationStatus.RESERVED) } @@ -110,10 +110,17 @@ class Reservation private constructor( } } + private fun checkAlreadyAccepted() { + if (reservationStatus.isReserved()) { + throw IllegalStateException(ALREADY_ACCEPTED_RESERVATION.message) + } + } + fun reject() { checkNone() checkPaidReservation(NOTPAID_RESERVATION_CANNOT_REJECT.message) checkAlreadyAcceptedForReject() + checkAlreadyRejected() changeReservationStatus(ReservationStatus.REJECTED) } @@ -130,6 +137,12 @@ class Reservation private constructor( } } + private fun checkAlreadyRejected() { + if (reservationStatus.isRejected()) { + throw IllegalStateException(ALREADY_REJECTED_RESERVATION.message) + } + } + fun pay() { checkNotPaid() @@ -142,6 +155,13 @@ class Reservation private constructor( } } + fun buildDescription(): String { + val firstItemName = lineItems.first().name + val size = lineItems.size - 1 + + return if (size == 0) firstItemName else "$firstItemName 외 ${size}건" + } + companion object { fun of( lineItems: List, diff --git a/mealkitary-domain/src/main/kotlin/com/mealkitary/reservation/domain/reservation/ReservationLineItem.kt b/mealkitary-domain/src/main/kotlin/com/mealkitary/reservation/domain/reservation/ReservationLineItem.kt index 0ab8b81..5a2995c 100644 --- a/mealkitary-domain/src/main/kotlin/com/mealkitary/reservation/domain/reservation/ReservationLineItem.kt +++ b/mealkitary-domain/src/main/kotlin/com/mealkitary/reservation/domain/reservation/ReservationLineItem.kt @@ -20,7 +20,8 @@ class ReservationLineItem private constructor( private val itemId: ProductId = itemId @Column(nullable = false) - private val name: String = name + var name: String = name + protected set @Embedded private val price: Money = price diff --git a/mealkitary-domain/src/test/kotlin/com/mealkitary/reservation/domain/reservation/ReservationTest.kt b/mealkitary-domain/src/test/kotlin/com/mealkitary/reservation/domain/reservation/ReservationTest.kt index 13643d2..8008261 100644 --- a/mealkitary-domain/src/test/kotlin/com/mealkitary/reservation/domain/reservation/ReservationTest.kt +++ b/mealkitary-domain/src/test/kotlin/com/mealkitary/reservation/domain/reservation/ReservationTest.kt @@ -184,6 +184,28 @@ class ReservationTest : AnnotationSpec() { } shouldHaveMessage "정상적으로 생성된 예약에 대해서만 수행 가능합니다." } + @Test + fun `이미 확정된 예약은 다시 수락될 수 없다`() { + val sut = ReservationTestData.defaultReservation() + .withReservationStatus(ReservationStatus.RESERVED) + .build() + + shouldThrow { + sut.accept() + } shouldHaveMessage "이미 승인된 예약입니다." + } + + @Test + fun `이미 거절된 예약은 다시 거절될 수 없다`() { + val sut = ReservationTestData.defaultReservation() + .withReservationStatus(ReservationStatus.REJECTED) + .build() + + shouldThrow { + sut.reject() + } shouldHaveMessage "이미 거절된 예약입니다." + } + @Test fun `이미 처리하고 있는 예약은 다시 예약 요청할 수 없다`() { val sut = paidReservation() @@ -235,6 +257,30 @@ class ReservationTest : AnnotationSpec() { totalPrice shouldBe Money.from(37000) } + @Test + fun `예약에 대한 개요를 생성한다`() { + val sut = ReservationTestData.defaultReservation() + .withReservationStatus(ReservationStatus.NOTPAID) + .withLineItems( + ReservationLineItem.of(ProductId(1L), "김치찌개", Money.from(1000), 10), + ReservationLineItem.of(ProductId(2L), "b", Money.from(9000), 2), + ReservationLineItem.of(ProductId(3L), "c", Money.from(3000), 3) + ).build() + + sut.buildDescription() shouldBe "김치찌개 외 2건" + } + + @Test + fun `예약 상품이 하나라면 ~외 N건을 생성하지 않는다`() { + val sut = ReservationTestData.defaultReservation() + .withReservationStatus(ReservationStatus.NOTPAID) + .withLineItems( + ReservationLineItem.of(ProductId(1L), "김치찌개", Money.from(1000), 10), + ).build() + + sut.buildDescription() shouldBe "김치찌개" + } + private fun paidReservation() = ReservationTestData.defaultReservation() .withReservationStatus(ReservationStatus.PAID) diff --git a/mealkitary-infrastructure/adapter-firebase-notification/build.gradle.kts b/mealkitary-infrastructure/adapter-firebase-notification/build.gradle.kts new file mode 100644 index 0000000..3d48435 --- /dev/null +++ b/mealkitary-infrastructure/adapter-firebase-notification/build.gradle.kts @@ -0,0 +1,5 @@ +dependencies { + val firebaseAdminVersion: String by properties + implementation(project(":mealkitary-application")) + implementation("com.google.firebase:firebase-admin:$firebaseAdminVersion") +} diff --git a/mealkitary-infrastructure/adapter-firebase-notification/src/main/kotlin/com/mealkitary/common/firebase/FirebaseNotificationAdapter.kt b/mealkitary-infrastructure/adapter-firebase-notification/src/main/kotlin/com/mealkitary/common/firebase/FirebaseNotificationAdapter.kt new file mode 100644 index 0000000..846ad12 --- /dev/null +++ b/mealkitary-infrastructure/adapter-firebase-notification/src/main/kotlin/com/mealkitary/common/firebase/FirebaseNotificationAdapter.kt @@ -0,0 +1,27 @@ +package com.mealkitary.common.firebase + +import com.mealkitary.common.firebase.message.ReservationAcceptedMessage +import com.mealkitary.common.firebase.message.ReservationCreatedMessage +import com.mealkitary.reservation.application.port.output.SendAcceptedReservationMessagePort +import com.mealkitary.reservation.application.port.output.SendNewReservationMessagePort +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import java.util.UUID + +@Component +class FirebaseNotificationAdapter( + @Value("\${admin.fcm.token}") + private val adminToken: String, + @Value("\${client.fcm.token}") + private val clientToken: String, + private val client: FirebaseNotificationClient +) : SendNewReservationMessagePort, SendAcceptedReservationMessagePort { + + override fun sendNewReservationMessage(reservationId: UUID, description: String) { + client.send(ReservationCreatedMessage("새로운 예약이 들어왔어요!", description, reservationId, adminToken)) + } + + override fun sendAcceptedReservationMessage() { + client.send(ReservationAcceptedMessage("예약이 승인됐어요!", clientToken)) + } +} diff --git a/mealkitary-infrastructure/adapter-firebase-notification/src/main/kotlin/com/mealkitary/common/firebase/FirebaseNotificationClient.kt b/mealkitary-infrastructure/adapter-firebase-notification/src/main/kotlin/com/mealkitary/common/firebase/FirebaseNotificationClient.kt new file mode 100644 index 0000000..314df32 --- /dev/null +++ b/mealkitary-infrastructure/adapter-firebase-notification/src/main/kotlin/com/mealkitary/common/firebase/FirebaseNotificationClient.kt @@ -0,0 +1,35 @@ +package com.mealkitary.common.firebase + +import com.google.firebase.messaging.FirebaseMessaging +import com.google.firebase.messaging.Message +import com.mealkitary.common.firebase.message.ReservationAcceptedMessage +import com.mealkitary.common.firebase.message.ReservationCreatedMessage +import org.springframework.stereotype.Component + +@Component +class FirebaseNotificationClient { + + fun send(reservationCreatedMessage: ReservationCreatedMessage) { + val message = Message.builder() + .putData("title", reservationCreatedMessage.title) + .putData("description", reservationCreatedMessage.description) + .putData("reservationId", reservationCreatedMessage.reservationId.toString()) + .setToken(reservationCreatedMessage.token) + .build() + + sendToFcm(message) + } + + fun send(reservationAcceptedMessage: ReservationAcceptedMessage) { + val message = Message.builder() + .putData("title", reservationAcceptedMessage.title) + .setToken(reservationAcceptedMessage.token) + .build() + + sendToFcm(message) + } + + private fun sendToFcm(message: Message) { + FirebaseMessaging.getInstance().sendAsync(message) + } +} diff --git a/mealkitary-infrastructure/adapter-firebase-notification/src/main/kotlin/com/mealkitary/common/firebase/FirebaseNotificationInitializer.kt b/mealkitary-infrastructure/adapter-firebase-notification/src/main/kotlin/com/mealkitary/common/firebase/FirebaseNotificationInitializer.kt new file mode 100644 index 0000000..11146dc --- /dev/null +++ b/mealkitary-infrastructure/adapter-firebase-notification/src/main/kotlin/com/mealkitary/common/firebase/FirebaseNotificationInitializer.kt @@ -0,0 +1,25 @@ +package com.mealkitary.common.firebase + +import com.google.auth.oauth2.GoogleCredentials +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import org.springframework.core.io.ClassPathResource +import org.springframework.stereotype.Component +import javax.annotation.PostConstruct + +@Component +class FirebaseNotificationInitializer { + + @PostConstruct + fun initialize() { + ClassPathResource("firebase.json").inputStream.use { + val options = FirebaseOptions.builder() + .setCredentials(GoogleCredentials.fromStream(it)) + .build() + + if (FirebaseApp.getApps().isEmpty()) { + FirebaseApp.initializeApp(options) + } + } + } +} diff --git a/mealkitary-infrastructure/adapter-firebase-notification/src/main/kotlin/com/mealkitary/common/firebase/message/ReservationAcceptedMessage.kt b/mealkitary-infrastructure/adapter-firebase-notification/src/main/kotlin/com/mealkitary/common/firebase/message/ReservationAcceptedMessage.kt new file mode 100644 index 0000000..755c2a6 --- /dev/null +++ b/mealkitary-infrastructure/adapter-firebase-notification/src/main/kotlin/com/mealkitary/common/firebase/message/ReservationAcceptedMessage.kt @@ -0,0 +1,6 @@ +package com.mealkitary.common.firebase.message + +data class ReservationAcceptedMessage( + val title: String, + val token: String +) diff --git a/mealkitary-infrastructure/adapter-firebase-notification/src/main/kotlin/com/mealkitary/common/firebase/message/ReservationCreatedMessage.kt b/mealkitary-infrastructure/adapter-firebase-notification/src/main/kotlin/com/mealkitary/common/firebase/message/ReservationCreatedMessage.kt new file mode 100644 index 0000000..6b80a48 --- /dev/null +++ b/mealkitary-infrastructure/adapter-firebase-notification/src/main/kotlin/com/mealkitary/common/firebase/message/ReservationCreatedMessage.kt @@ -0,0 +1,10 @@ +package com.mealkitary.common.firebase.message + +import java.util.UUID + +data class ReservationCreatedMessage( + val title: String, + val description: String, + val reservationId: UUID, + val token: String +) diff --git a/mealkitary-infrastructure/adapter-firebase-notification/src/main/resources/.gitkeep b/mealkitary-infrastructure/adapter-firebase-notification/src/main/resources/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/mealkitary-infrastructure/adapter-firebase-notification/src/test/kotlin/com/mealkitary/common/firebase/FirebaseNotificationAdapterTest.kt b/mealkitary-infrastructure/adapter-firebase-notification/src/test/kotlin/com/mealkitary/common/firebase/FirebaseNotificationAdapterTest.kt new file mode 100644 index 0000000..7abe8d0 --- /dev/null +++ b/mealkitary-infrastructure/adapter-firebase-notification/src/test/kotlin/com/mealkitary/common/firebase/FirebaseNotificationAdapterTest.kt @@ -0,0 +1,43 @@ +package com.mealkitary.common.firebase + +import com.mealkitary.common.firebase.message.ReservationAcceptedMessage +import com.mealkitary.common.firebase.message.ReservationCreatedMessage +import io.kotest.core.spec.style.AnnotationSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import java.util.UUID + +class FirebaseNotificationAdapterTest : AnnotationSpec() { + + private val client = mockk() + private val adapterUnderTest = FirebaseNotificationAdapter("test-admin-token", "test-client-token", client) + + @Test + fun `notification adapter unit test - sendNewReservationMessage`() { + val reservationId = UUID.randomUUID() + val slot = slot() + every { client.send(capture(slot)) } answers {} + + adapterUnderTest.sendNewReservationMessage(reservationId, "부대찌개 외 2건") + + val actual = slot.captured + actual.token shouldBe "test-admin-token" + actual.title shouldBe "새로운 예약이 들어왔어요!" + actual.description shouldBe "부대찌개 외 2건" + actual.reservationId shouldBe reservationId + } + + @Test + fun `notification adapter unit test - sendAcceptedReservationMessage`() { + val slot = slot() + every { client.send(capture(slot)) } answers {} + + adapterUnderTest.sendAcceptedReservationMessage() + + val actual = slot.captured + actual.token shouldBe "test-client-token" + actual.title shouldBe "예약이 승인됐어요!" + } +} diff --git a/mealkitary-infrastructure/adapter-persistence-spring-data-jpa/src/test/kotlin/com/mealkitary/PersistenceIntegrationTestSupport.kt b/mealkitary-infrastructure/adapter-persistence-spring-data-jpa/src/test/kotlin/com/mealkitary/PersistenceIntegrationTestSupport.kt index 60d8731..0d9967f 100644 --- a/mealkitary-infrastructure/adapter-persistence-spring-data-jpa/src/test/kotlin/com/mealkitary/PersistenceIntegrationTestSupport.kt +++ b/mealkitary-infrastructure/adapter-persistence-spring-data-jpa/src/test/kotlin/com/mealkitary/PersistenceIntegrationTestSupport.kt @@ -1,6 +1,7 @@ package com.mealkitary -import com.mealkitary.reservation.domain.payment.PaymentGatewayService +import com.mealkitary.reservation.application.service.AcceptReservationService +import com.mealkitary.reservation.application.service.PayReservationService import com.ninjasquad.springmockk.MockkBean import io.kotest.core.spec.style.AnnotationSpec import io.kotest.extensions.spring.SpringExtension @@ -23,5 +24,8 @@ abstract class PersistenceIntegrationTestSupport : AnnotationSpec() { protected lateinit var emf: EntityManagerFactory @MockkBean - private lateinit var paymentGatewayService: PaymentGatewayService + private lateinit var payReservationService: PayReservationService + + @MockkBean + private lateinit var acceptReservationService: AcceptReservationService } diff --git a/settings.gradle.kts b/settings.gradle.kts index 664a11c..c8c34b2 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -5,7 +5,8 @@ include( "mealkitary-application", "mealkitary-domain", "mealkitary-infrastructure:adapter-persistence-spring-data-jpa", - "mealkitary-infrastructure:adapter-paymentgateway-tosspayments" + "mealkitary-infrastructure:adapter-paymentgateway-tosspayments", + "mealkitary-infrastructure:adapter-firebase-notification" ) pluginManagement {