diff --git a/.github/workflows/mealkitary-main-develop-ci.yml b/.github/workflows/mealkitary-main-develop-ci.yml index 901d7f2..50aafda 100644 --- a/.github/workflows/mealkitary-main-develop-ci.yml +++ b/.github/workflows/mealkitary-main-develop-ci.yml @@ -54,7 +54,9 @@ jobs: ./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 - ./mealkitary-infrastructure/business-registration-number-validator/adapter-simple-brn-validator/build/test-results/**/*.xml + ./mealkitary-infrastructure/adapter-business-registration-number-validator/simple-brn-validator/build/test-results/**/*.xml + ./mealkitary-infrastructure/adapter-business-registration-number-validator/open-api-brn-validator/build/test-results/**/*.xml + ./mealkitary-infrastructure/adapter-address-resolver/build/test-results/**/*.xml - name: Jacoco Coverage 리포트 전송 uses: codecov/codecov-action@v3 @@ -67,7 +69,9 @@ jobs: ./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-firebase-notification/build/reports/jacoco/test/jacocoTestReport.xml, - ./mealkitary-infrastructure/business-registration-number-validator/adapter-simple-brn-validator/build/reports/jacoco/test/jacocoTestReport.xml + ./mealkitary-infrastructure/adapter-business-registration-number-validator/simple-brn-validator/build/reports/jacoco/test/jacocoTestReport.xml, + ./mealkitary-infrastructure/adapter-business-registration-number-validator/open-api-brn-validator/build/reports/jacoco/test/jacocoTestReport.xml, + ./mealkitary-infrastructure/adapter-address-resolver/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 acd71d2..6d94ef1 100644 --- a/.github/workflows/mealkitary-test-coverage-automation.yml +++ b/.github/workflows/mealkitary-test-coverage-automation.yml @@ -42,7 +42,9 @@ jobs: ./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 - ./mealkitary-infrastructure/business-registration-number-validator/adapter-simple-brn-validator/build/test-results/**/*.xml + ./mealkitary-infrastructure/adapter-business-registration-number-validator/simple-brn-validator/build/test-results/**/*.xml + ./mealkitary-infrastructure/adapter-business-registration-number-validator/open-api-brn-validator/build/test-results/**/*.xml + ./mealkitary-infrastructure/adapter-address-resolver/build/test-results/**/*.xml - name: Jacoco Coverage 리포트 전송 uses: codecov/codecov-action@v3 @@ -55,6 +57,8 @@ jobs: ./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-firebase-notification/build/reports/jacoco/test/jacocoTestReport.xml, - ./mealkitary-infrastructure/business-registration-number-validator/adapter-simple-brn-validator/build/reports/jacoco/test/jacocoTestReport.xml + ./mealkitary-infrastructure/adapter-business-registration-number-validator/simple-brn-validator/build/reports/jacoco/test/jacocoTestReport.xml, + ./mealkitary-infrastructure/adapter-business-registration-number-validator/open-api-brn-validator/build/reports/jacoco/test/jacocoTestReport.xml, + ./mealkitary-infrastructure/adapter-address-resolver/build/reports/jacoco/test/jacocoTestReport.xml name: mealkitary-codecov verbose: true diff --git a/gradle.properties b/gradle.properties index 26aeb68..a8bc6e7 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.5.0 +applicationVersion=0.5.1 projectGroup=com.mealkitary # test kotestVersion=4.4.3 diff --git a/mealkitary-api/build.gradle.kts b/mealkitary-api/build.gradle.kts index 5ffe9a6..ae0108b 100644 --- a/mealkitary-api/build.gradle.kts +++ b/mealkitary-api/build.gradle.kts @@ -6,6 +6,7 @@ val snippetsDir by extra { file("build/generated-snippets") } val asciidoctorExt: Configuration by configurations.creating bootJar.enabled = true +bootJar.duplicatesStrategy = DuplicatesStrategy.EXCLUDE jar.enabled = false plugins { @@ -21,14 +22,16 @@ dependencies { implementation(project(":mealkitary-infrastructure:adapter-persistence-spring-data-jpa")) implementation(project(":mealkitary-infrastructure:adapter-paymentgateway-tosspayments")) implementation(project(":mealkitary-infrastructure:adapter-firebase-notification")) + implementation(project(":mealkitary-infrastructure:adapter-configuration")) + implementation(project(":mealkitary-infrastructure:adapter-address-resolver")) implementation( project( - ":mealkitary-infrastructure:business-registration-number-validator:adapter-open-api-brn-validator", + ":mealkitary-infrastructure:adapter-business-registration-number-validator:open-api-brn-validator", ) ) implementation( project( - ":mealkitary-infrastructure:business-registration-number-validator:adapter-simple-brn-validator", + ":mealkitary-infrastructure:adapter-business-registration-number-validator:simple-brn-validator", ) ) testImplementation("org.springframework.restdocs:spring-restdocs-mockmvc") diff --git a/mealkitary-api/src/main/kotlin/com/mealkitary/shop/web/request/RegisterShopWebRequest.kt b/mealkitary-api/src/main/kotlin/com/mealkitary/shop/web/request/RegisterShopWebRequest.kt index 034c616..2cdc615 100644 --- a/mealkitary-api/src/main/kotlin/com/mealkitary/shop/web/request/RegisterShopWebRequest.kt +++ b/mealkitary-api/src/main/kotlin/com/mealkitary/shop/web/request/RegisterShopWebRequest.kt @@ -8,8 +8,11 @@ data class RegisterShopWebRequest( val title: String? = null, @field:NotBlank(message = "사업자 번호는 필수입니다.") - val brn: String? = null + val brn: String? = null, + + @field:NotBlank(message = "주소는 필수입니다.") + val address: String? = null ) { - fun mapToServiceRequest() = RegisterShopRequest(title!!, brn!!) + fun mapToServiceRequest() = RegisterShopRequest(title!!, brn!!, address!!) } diff --git a/mealkitary-api/src/test/kotlin/com/docs/shop/RegisterShopControllerDocsTest.kt b/mealkitary-api/src/test/kotlin/com/docs/shop/RegisterShopControllerDocsTest.kt index 7006d8f..c00ef51 100644 --- a/mealkitary-api/src/test/kotlin/com/docs/shop/RegisterShopControllerDocsTest.kt +++ b/mealkitary-api/src/test/kotlin/com/docs/shop/RegisterShopControllerDocsTest.kt @@ -28,7 +28,7 @@ class RegisterShopControllerDocsTest : RestDocsSupport() { fun `api docs test - registerShop`() { every { registerShopUseCase.register(any()) } answers { 1L } - val registerShopWebRequest = RegisterShopWebRequest("집밥뚝딱 안양점", "123-23-12345") + val registerShopWebRequest = RegisterShopWebRequest("집밥뚝딱 안양점", "123-23-12345", "경기도 안양시 동안구 벌말로") mvc.perform( RestDocumentationRequestBuilders.post("/shops") @@ -45,6 +45,7 @@ class RegisterShopControllerDocsTest : RestDocsSupport() { requestFields( fieldWithPath("title").type(JsonFieldType.STRING).description("등록 대상 가게 이름"), fieldWithPath("brn").type(JsonFieldType.STRING).description("사업자 번호"), + fieldWithPath("address").type(JsonFieldType.STRING).description("가게 도로명 주소"), ), responseHeaders(headerWithName("Location").description("생성된 가게 리소스 URI")), ) diff --git a/mealkitary-api/src/test/kotlin/com/mealkitary/shop/web/RegisterShopControllerTest.kt b/mealkitary-api/src/test/kotlin/com/mealkitary/shop/web/RegisterShopControllerTest.kt index f62186a..521da7c 100644 --- a/mealkitary-api/src/test/kotlin/com/mealkitary/shop/web/RegisterShopControllerTest.kt +++ b/mealkitary-api/src/test/kotlin/com/mealkitary/shop/web/RegisterShopControllerTest.kt @@ -15,7 +15,7 @@ class RegisterShopControllerTest : WebIntegrationTestSupport() { fun `api integration test - registerShop`() { every { registerShopUseCase.register(any()) } answers { 1L } - val registerShopWebRequest = RegisterShopWebRequest("집밥뚝딱 안양점", "123-23-12345") + val registerShopWebRequest = RegisterShopWebRequest("집밥뚝딱 안양점", "123-23-12345", "경기도 안양시 동안구 벌말로 40") mvc.perform( MockMvcRequestBuilders.post("/shops") @@ -28,7 +28,7 @@ class RegisterShopControllerTest : WebIntegrationTestSupport() { @Test fun `api integration test - 가게 이름이 누락된 경우 400 에러를 발생한다`() { - val registerShopWebRequest = RegisterShopWebRequest(brn = "123-23-12345") + val registerShopWebRequest = RegisterShopWebRequest(brn = "123-23-12345", address = "경기도 안양시 동안구 벌말로 40") mvc.perform( MockMvcRequestBuilders.post("/shops") @@ -44,7 +44,7 @@ class RegisterShopControllerTest : WebIntegrationTestSupport() { @Test fun `api integration test - 사업자 번호가 누락된 경우 400 에러를 발생한다`() { - val registerShopWebRequest = RegisterShopWebRequest(title = "집밥뚝딱 안양점") + val registerShopWebRequest = RegisterShopWebRequest(title = "집밥뚝딱 안양점", address = "경기도 안양시 동안구 벌말로 40") mvc.perform( MockMvcRequestBuilders.post("/shops") @@ -58,6 +58,22 @@ class RegisterShopControllerTest : WebIntegrationTestSupport() { .andExpect(jsonPath("$..errors[0].reason").value("사업자 번호는 필수입니다.")) } + @Test + fun `api integration test - 주소가 누락된 경우 400 에러를 발생한다`() { + val registerShopWebRequest = RegisterShopWebRequest(title = "집밥뚝딱 안양점", brn = "123-23-12345") + + mvc.perform( + MockMvcRequestBuilders.post("/shops") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(registerShopWebRequest)) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.status").value("400")) + .andExpect(jsonPath("$.message").value("잘못된 입력값입니다.")) + .andExpect(jsonPath("$..errors[0].field").value("address")) + .andExpect(jsonPath("$..errors[0].reason").value("주소는 필수입니다.")) + } + @Test fun `api integration test - JSON 형식이 아닌 경우 400 에러가 발생한다`() { mvc.perform( diff --git a/mealkitary-application/src/main/kotlin/com/mealkitary/shop/application/port/input/RegisterShopRequest.kt b/mealkitary-application/src/main/kotlin/com/mealkitary/shop/application/port/input/RegisterShopRequest.kt index b11e2f1..9598f31 100644 --- a/mealkitary-application/src/main/kotlin/com/mealkitary/shop/application/port/input/RegisterShopRequest.kt +++ b/mealkitary-application/src/main/kotlin/com/mealkitary/shop/application/port/input/RegisterShopRequest.kt @@ -2,5 +2,6 @@ package com.mealkitary.shop.application.port.input data class RegisterShopRequest( val title: String, - val brn: String + val brn: String, + val address: String ) diff --git a/mealkitary-application/src/main/kotlin/com/mealkitary/shop/application/service/RegisterShopService.kt b/mealkitary-application/src/main/kotlin/com/mealkitary/shop/application/service/RegisterShopService.kt index 8da3cab..f36d12c 100644 --- a/mealkitary-application/src/main/kotlin/com/mealkitary/shop/application/service/RegisterShopService.kt +++ b/mealkitary-application/src/main/kotlin/com/mealkitary/shop/application/service/RegisterShopService.kt @@ -3,23 +3,23 @@ package com.mealkitary.shop.application.service import com.mealkitary.shop.application.port.input.RegisterShopRequest import com.mealkitary.shop.application.port.input.RegisterShopUseCase import com.mealkitary.shop.application.port.output.SaveShopPort -import com.mealkitary.shop.domain.shop.factory.ShopBusinessNumberValidator import com.mealkitary.shop.domain.shop.factory.ShopFactory import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @Service -@Transactional(readOnly = true) +@Transactional class RegisterShopService( private val saveShopPort: SaveShopPort, - shopBusinessNumberValidator: ShopBusinessNumberValidator + private val shopFactory: ShopFactory ) : RegisterShopUseCase { - private val shopFactory = ShopFactory(shopBusinessNumberValidator) - - @Transactional override fun register(registerShopRequest: RegisterShopRequest): Long { - val shop = shopFactory.createOne(registerShopRequest.title, registerShopRequest.brn) + val shop = shopFactory.createOne( + registerShopRequest.title, + registerShopRequest.brn, + registerShopRequest.address + ) return saveShopPort.saveOne(shop) } diff --git a/mealkitary-application/src/test/kotlin/com/mealkitary/shop/application/service/GetShopServiceTest.kt b/mealkitary-application/src/test/kotlin/com/mealkitary/shop/application/service/GetShopServiceTest.kt index 23b9d34..90f93eb 100644 --- a/mealkitary-application/src/test/kotlin/com/mealkitary/shop/application/service/GetShopServiceTest.kt +++ b/mealkitary-application/src/test/kotlin/com/mealkitary/shop/application/service/GetShopServiceTest.kt @@ -1,11 +1,14 @@ package com.mealkitary.shop.application.service +import com.mealkitary.common.model.Address +import com.mealkitary.common.model.Coordinates import com.mealkitary.shop.application.port.input.ShopResponse import com.mealkitary.shop.application.port.output.LoadShopPort import com.mealkitary.shop.domain.shop.Shop import com.mealkitary.shop.domain.shop.ShopBusinessNumber import com.mealkitary.shop.domain.shop.ShopStatus import com.mealkitary.shop.domain.shop.ShopTitle +import com.mealkitary.shop.domain.shop.address.ShopAddress import io.kotest.core.spec.style.AnnotationSpec import io.kotest.matchers.shouldBe import io.mockk.every @@ -24,6 +27,19 @@ class GetShopServiceTest : AnnotationSpec() { ShopTitle.from("집밥뚝딱"), ShopStatus.VALID, ShopBusinessNumber.from("123-45-67890"), + ShopAddress.of( + "1234567890", + Coordinates.of( + 126.99599512792346, + 35.976749396987046 + ), + Address.of( + "region1DepthName", + "region2DepthName", + "region3DepthName", + "roadName" + ) + ), mutableListOf(), mutableListOf() ) diff --git a/mealkitary-application/src/test/kotlin/com/mealkitary/shop/application/service/RegisterShopServiceTest.kt b/mealkitary-application/src/test/kotlin/com/mealkitary/shop/application/service/RegisterShopServiceTest.kt index 2e2e6be..e61ca1c 100644 --- a/mealkitary-application/src/test/kotlin/com/mealkitary/shop/application/service/RegisterShopServiceTest.kt +++ b/mealkitary-application/src/test/kotlin/com/mealkitary/shop/application/service/RegisterShopServiceTest.kt @@ -1,9 +1,18 @@ package com.mealkitary.shop.application.service +import com.mealkitary.common.model.Address +import com.mealkitary.common.model.Coordinates import com.mealkitary.shop.application.port.input.RegisterShopRequest import com.mealkitary.shop.application.port.output.SaveShopPort +import com.mealkitary.shop.domain.product.Product import com.mealkitary.shop.domain.shop.Shop +import com.mealkitary.shop.domain.shop.ShopBusinessNumber +import com.mealkitary.shop.domain.shop.ShopStatus +import com.mealkitary.shop.domain.shop.ShopTitle +import com.mealkitary.shop.domain.shop.address.ShopAddress +import com.mealkitary.shop.domain.shop.factory.AddressResolver import com.mealkitary.shop.domain.shop.factory.ShopBusinessNumberValidator +import com.mealkitary.shop.domain.shop.factory.ShopFactory import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.AnnotationSpec import io.kotest.matchers.collections.shouldBeEmpty @@ -12,19 +21,36 @@ import io.kotest.matchers.throwable.shouldHaveMessage import io.mockk.every import io.mockk.mockk import io.mockk.slot +import java.time.LocalTime class RegisterShopServiceTest : AnnotationSpec() { private val saveShopPort = mockk() + private val shopFactory = mockk() private val shopBusinessNumberValidator = mockk() - private val registerShopService = RegisterShopService(saveShopPort, shopBusinessNumberValidator) + private val addressResolver = mockk() + private val registerShopService = RegisterShopService(saveShopPort, shopFactory) @Test fun `service unit test - 신규 가게를 등록한다`() { val shopSlot = slot() - val request = RegisterShopRequest("집밥뚝딱 안양점", "123-23-12345") + val request = RegisterShopRequest("집밥뚝딱 안양점", "123-23-12345", "경기도 안양시 동안구 벌말로 40") + val expectedShopAddress = + ShopAddress.of("1234567890", Coordinates.of(0.0, 0.0), Address.of("경기도", "안양시 동안구", "벌말로", "40")) + + val mockedShop = Shop( + ShopTitle.from(request.title), + ShopStatus.VALID, + ShopBusinessNumber.from(request.brn), + expectedShopAddress, + emptyList().toMutableList(), + emptyList().toMutableList() + ) + + every { + shopFactory.createOne(request.title, request.brn, request.address) + } returns mockedShop every { saveShopPort.saveOne(capture(shopSlot)) } answers { 1L } - every { shopBusinessNumberValidator.validate(any()) } answers {} val result = registerShopService.register(request) @@ -32,15 +58,23 @@ class RegisterShopServiceTest : AnnotationSpec() { result shouldBe 1L capturedShop.businessNumber.value shouldBe "123-23-12345" capturedShop.title.value shouldBe "집밥뚝딱 안양점" + capturedShop.address shouldBe expectedShopAddress capturedShop.products.shouldBeEmpty() capturedShop.reservableTimes.shouldBeEmpty() } @Test fun `service unit test - 가게 이름 형식에 맞지 않으면 예외를 발생한다`() { - val request = RegisterShopRequest("invalid!#@", "123-23-12345") + val request = RegisterShopRequest("invalid!#@", "123-23-12345", "경기도 안양시 동안구 벌말로 40") + val expectedShopAddress = + ShopAddress.of("1234567890", Coordinates.of(0.0, 0.0), Address.of("경기도", "안양시 동안구", "벌말로", "40")) + + every { + shopFactory.createOne(any(), any(), any()) + } throws IllegalArgumentException("올바른 가게 이름 형식이 아닙니다.(한글, 영문, 공백, 숫자만 포함 가능)") every { saveShopPort.saveOne(any()) } answers { 1L } every { shopBusinessNumberValidator.validate(any()) } answers {} + every { addressResolver.resolveAddress("경기도 안양시 동안구 벌말로 40") } returns expectedShopAddress shouldThrow { registerShopService.register(request) @@ -49,7 +83,11 @@ class RegisterShopServiceTest : AnnotationSpec() { @Test fun `service unit test - 사업자 번호 형식에 맞지 않으면 예외를 발생한다`() { - val request = RegisterShopRequest("집밥뚝딱 안양점", "invalid-brn") + val request = RegisterShopRequest("집밥뚝딱 안양점", "invalid-brn", "경기도 안양시 동안구 벌말로 40") + + every { + shopFactory.createOne(any(), any(), any()) + } throws IllegalArgumentException("올바른 사업자번호 형식이 아닙니다.") every { saveShopPort.saveOne(any()) } answers { 1L } every { shopBusinessNumberValidator.validate(any()) } answers {} diff --git a/mealkitary-application/src/test/kotlin/com/mealkitary/shop/application/service/UpdateShopStatusServiceTest.kt b/mealkitary-application/src/test/kotlin/com/mealkitary/shop/application/service/UpdateShopStatusServiceTest.kt index b755b9a..d8e47f7 100644 --- a/mealkitary-application/src/test/kotlin/com/mealkitary/shop/application/service/UpdateShopStatusServiceTest.kt +++ b/mealkitary-application/src/test/kotlin/com/mealkitary/shop/application/service/UpdateShopStatusServiceTest.kt @@ -1,11 +1,14 @@ package com.mealkitary.shop.application.service +import com.mealkitary.common.model.Address +import com.mealkitary.common.model.Coordinates import com.mealkitary.shop.application.port.output.CheckExistenceShopPort import com.mealkitary.shop.application.port.output.LoadShopPort import com.mealkitary.shop.domain.shop.Shop import com.mealkitary.shop.domain.shop.ShopBusinessNumber import com.mealkitary.shop.domain.shop.ShopStatus import com.mealkitary.shop.domain.shop.ShopTitle +import com.mealkitary.shop.domain.shop.address.ShopAddress import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.AnnotationSpec import io.kotest.matchers.shouldBe @@ -25,6 +28,19 @@ class UpdateShopStatusServiceTest : AnnotationSpec() { ShopTitle.from("제목"), ShopStatus.VALID, ShopBusinessNumber.from("123-12-12345"), + ShopAddress.of( + "1234567890", + Coordinates.of( + 126.99599512792346, + 35.976749396987046 + ), + Address.of( + "region1DepthName", + "region2DepthName", + "region3DepthName", + "roadName" + ) + ), mutableListOf(), mutableListOf() ) @@ -44,6 +60,19 @@ class UpdateShopStatusServiceTest : AnnotationSpec() { ShopTitle.from("제목"), ShopStatus.INVALID, ShopBusinessNumber.from("123-12-12345"), + ShopAddress.of( + "1234567890", + Coordinates.of( + 126.99599512792346, + 35.976749396987046 + ), + Address.of( + "region1DepthName", + "region2DepthName", + "region3DepthName", + "roadName" + ) + ), mutableListOf(), mutableListOf() ) @@ -63,6 +92,19 @@ class UpdateShopStatusServiceTest : AnnotationSpec() { ShopTitle.from("제목"), ShopStatus.VALID, ShopBusinessNumber.from("123-12-12345"), + ShopAddress.of( + "1234567890", + Coordinates.of( + 126.99599512792346, + 35.976749396987046 + ), + Address.of( + "region1DepthName", + "region2DepthName", + "region3DepthName", + "roadName" + ) + ), mutableListOf(), mutableListOf() ) diff --git a/mealkitary-domain/src/main/kotlin/com/mealkitary/common/model/Address.kt b/mealkitary-domain/src/main/kotlin/com/mealkitary/common/model/Address.kt new file mode 100644 index 0000000..099d9d3 --- /dev/null +++ b/mealkitary-domain/src/main/kotlin/com/mealkitary/common/model/Address.kt @@ -0,0 +1,34 @@ +package com.mealkitary.common.model + +import javax.persistence.Column +import javax.persistence.Embeddable + +@Embeddable +class Address private constructor( + @Column(name = "region_1depth_name", nullable = false) + val region1DepthName: String, + @Column(name = "region_2depth_name", nullable = false) + val region2DepthName: String, + @Column(name = "region_3depth_name") + val region3DepthName: String, + @Column(name = "road_name") + val roadName: String +) { + + companion object { + fun of( + region1DepthName: String, + region2DepthName: String, + region3DepthName: String, + roadName: String + ): Address { + + return Address( + region1DepthName, + region2DepthName, + region3DepthName, + roadName + ) + } + } +} diff --git a/mealkitary-domain/src/main/kotlin/com/mealkitary/common/model/Coordinates.kt b/mealkitary-domain/src/main/kotlin/com/mealkitary/common/model/Coordinates.kt new file mode 100644 index 0000000..d11bd0f --- /dev/null +++ b/mealkitary-domain/src/main/kotlin/com/mealkitary/common/model/Coordinates.kt @@ -0,0 +1,27 @@ +package com.mealkitary.common.model + +import javax.persistence.Column +import javax.persistence.Embeddable + +@Embeddable +class Coordinates( + @Column(name = "longitude", nullable = false) + val longitude: Double, + @Column(name = "latitude", nullable = false) + val latitude: Double +) { + + companion object { + fun of(longitude: Double, latitude: Double): Coordinates { + checkIsCoordinateRange(longitude, latitude) + + return Coordinates(longitude, latitude) + } + + private fun checkIsCoordinateRange(longitude: Double, latitude: Double) { + if (longitude !in -180.0..180.0 || latitude !in -90.0..90.0) { + throw IllegalArgumentException("유효하지 않은 좌표 범위입니다.") + } + } + } +} diff --git a/mealkitary-domain/src/main/kotlin/com/mealkitary/shop/domain/shop/Shop.kt b/mealkitary-domain/src/main/kotlin/com/mealkitary/shop/domain/shop/Shop.kt index 79dcbe6..352c3cc 100644 --- a/mealkitary-domain/src/main/kotlin/com/mealkitary/shop/domain/shop/Shop.kt +++ b/mealkitary-domain/src/main/kotlin/com/mealkitary/shop/domain/shop/Shop.kt @@ -1,6 +1,7 @@ package com.mealkitary.shop.domain.shop import com.mealkitary.shop.domain.product.Product +import com.mealkitary.shop.domain.shop.address.ShopAddress import java.time.LocalDateTime import java.time.LocalTime import javax.persistence.CascadeType @@ -23,6 +24,7 @@ class Shop( title: ShopTitle, status: ShopStatus, businessNumber: ShopBusinessNumber, + address: ShopAddress, reservableTimes: MutableList, products: MutableList ) { @@ -58,6 +60,8 @@ class Shop( val businessNumber: ShopBusinessNumber = businessNumber + val address: ShopAddress = address + fun checkReservableShop() { if (status.isInvalidStatus()) { throw IllegalStateException("유효하지 않은 가게입니다.") diff --git a/mealkitary-domain/src/main/kotlin/com/mealkitary/shop/domain/shop/address/ShopAddress.kt b/mealkitary-domain/src/main/kotlin/com/mealkitary/shop/domain/shop/address/ShopAddress.kt new file mode 100644 index 0000000..2229f42 --- /dev/null +++ b/mealkitary-domain/src/main/kotlin/com/mealkitary/shop/domain/shop/address/ShopAddress.kt @@ -0,0 +1,34 @@ +package com.mealkitary.shop.domain.shop.address + +import com.mealkitary.common.model.Address +import com.mealkitary.common.model.Coordinates +import javax.persistence.Column +import javax.persistence.Embeddable +import javax.persistence.Embedded + +private const val CITY_CODE_LENGTH = 10 + +@Embeddable +class ShopAddress private constructor( + @Column(name = "city_code", nullable = false) + val cityCode: String, + @Embedded + val coordinates: Coordinates, + @Embedded + val address: Address +) { + + companion object { + fun of(cityCode: String, coordinates: Coordinates, address: Address): ShopAddress { + checkIsCityCodeLength(cityCode) + + return ShopAddress(cityCode, coordinates, address) + } + + private fun checkIsCityCodeLength(cityCode: String) { + if (cityCode.length != CITY_CODE_LENGTH) { + throw IllegalArgumentException("올바른 지역 코드가 아닙니다. (행정동 지역 코드는 10자리)") + } + } + } +} diff --git a/mealkitary-domain/src/main/kotlin/com/mealkitary/shop/domain/shop/factory/AddressResolver.kt b/mealkitary-domain/src/main/kotlin/com/mealkitary/shop/domain/shop/factory/AddressResolver.kt new file mode 100644 index 0000000..83c3e12 --- /dev/null +++ b/mealkitary-domain/src/main/kotlin/com/mealkitary/shop/domain/shop/factory/AddressResolver.kt @@ -0,0 +1,8 @@ +package com.mealkitary.shop.domain.shop.factory + +import com.mealkitary.shop.domain.shop.address.ShopAddress + +interface AddressResolver { + + fun resolveAddress(address: String): ShopAddress +} diff --git a/mealkitary-domain/src/main/kotlin/com/mealkitary/shop/domain/shop/factory/ShopFactory.kt b/mealkitary-domain/src/main/kotlin/com/mealkitary/shop/domain/shop/factory/ShopFactory.kt index 76cc4df..da6efee 100644 --- a/mealkitary-domain/src/main/kotlin/com/mealkitary/shop/domain/shop/factory/ShopFactory.kt +++ b/mealkitary-domain/src/main/kotlin/com/mealkitary/shop/domain/shop/factory/ShopFactory.kt @@ -5,21 +5,28 @@ import com.mealkitary.shop.domain.shop.Shop import com.mealkitary.shop.domain.shop.ShopBusinessNumber import com.mealkitary.shop.domain.shop.ShopStatus import com.mealkitary.shop.domain.shop.ShopTitle +import com.mealkitary.shop.domain.shop.address.ShopAddress +import org.springframework.stereotype.Component import java.time.LocalTime +@Component class ShopFactory( - private val shopBusinessNumberValidator: ShopBusinessNumberValidator + private val shopBusinessNumberValidator: ShopBusinessNumberValidator, + private val addressResolver: AddressResolver ) { - fun createOne(title: String, brn: String): Shop { + fun createOne(title: String, brn: String, address: String): Shop { val shopBusinessNumber = ShopBusinessNumber.from(brn) + val shopAddress: ShopAddress = addressResolver.resolveAddress(address) + shopBusinessNumberValidator.validate(shopBusinessNumber) return Shop( ShopTitle.from(title), ShopStatus.VALID, shopBusinessNumber, + shopAddress, emptyList().toMutableList(), emptyList().toMutableList(), ) diff --git a/mealkitary-domain/src/test/kotlin/com/mealkitary/shop/domain/shop/AddressTest.kt b/mealkitary-domain/src/test/kotlin/com/mealkitary/shop/domain/shop/AddressTest.kt new file mode 100644 index 0000000..2c26e06 --- /dev/null +++ b/mealkitary-domain/src/test/kotlin/com/mealkitary/shop/domain/shop/AddressTest.kt @@ -0,0 +1,28 @@ +package com.mealkitary.shop.domain.shop + +import com.mealkitary.common.model.Address +import io.kotest.core.spec.style.AnnotationSpec +import io.kotest.matchers.shouldBe + +class AddressTest : AnnotationSpec() { + + @Test + fun `올바른 주소를 입력할 경우 객체를 생성할 수 있다`() { + val region1DepthName = "서울" + val region2DepthName = "강남구" + val region3DepthName = "논현동" + val roadName = "논현로" + + val address = Address.of( + region1DepthName, + region2DepthName, + region3DepthName, + roadName + ) + + address.region1DepthName shouldBe region1DepthName + address.region2DepthName shouldBe region2DepthName + address.region3DepthName shouldBe region3DepthName + address.roadName shouldBe roadName + } +} diff --git a/mealkitary-domain/src/test/kotlin/com/mealkitary/shop/domain/shop/CoordinatesTest.kt b/mealkitary-domain/src/test/kotlin/com/mealkitary/shop/domain/shop/CoordinatesTest.kt new file mode 100644 index 0000000..970576a --- /dev/null +++ b/mealkitary-domain/src/test/kotlin/com/mealkitary/shop/domain/shop/CoordinatesTest.kt @@ -0,0 +1,31 @@ +package com.mealkitary.shop.domain.shop + +import com.mealkitary.common.model.Coordinates +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.AnnotationSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.throwable.shouldHaveMessage + +class CoordinatesTest : AnnotationSpec() { + + @Test + fun `범위를 벗어나는 좌표일 경우 예외를 발생한다`() { + val longitude = -188.023 + val latitude = 999.7412 + + shouldThrow { + Coordinates.of(longitude, latitude) + } shouldHaveMessage "유효하지 않은 좌표 범위입니다." + } + + @Test + fun `올바른 좌표를 입력했을 경우 객체를 생성한다`() { + val longitude = -150.653 + val latitude = 46.492 + + val coordinates = Coordinates.of(longitude, latitude) + + coordinates.longitude shouldBe longitude + coordinates.latitude shouldBe latitude + } +} diff --git a/mealkitary-domain/src/test/kotlin/com/mealkitary/shop/domain/shop/ShopAddressTest.kt b/mealkitary-domain/src/test/kotlin/com/mealkitary/shop/domain/shop/ShopAddressTest.kt new file mode 100644 index 0000000..d490ebd --- /dev/null +++ b/mealkitary-domain/src/test/kotlin/com/mealkitary/shop/domain/shop/ShopAddressTest.kt @@ -0,0 +1,60 @@ +package com.mealkitary.shop.domain.shop + +import com.mealkitary.common.model.Address +import com.mealkitary.common.model.Coordinates +import com.mealkitary.shop.domain.shop.address.ShopAddress +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.AnnotationSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.throwable.shouldHaveMessage + +class ShopAddressTest : AnnotationSpec() { + + @Test + fun `올바른 값들을 입력했을 경우 객체를 생성할 수 있다`() { + val cityCode = "1234567890" + val coordinates = Coordinates.of( + -150.653, + 46.492 + ) + val address = Address.of( + "region1DepthName", + "region2DepthName", + "region3DepthName", + "roadName" + ) + + val shopAddress = ShopAddress.of( + cityCode, + coordinates, + address + ) + + shopAddress.cityCode shouldBe cityCode + shopAddress.coordinates shouldBe coordinates + shopAddress.address shouldBe address + } + + @Test + fun `지역 코드가 올바르지 않을 경우 예외를 발생한다`() { + val cityCode = "25231491723109" + val coordinates = Coordinates.of( + -150.653, + 46.492 + ) + val address = Address.of( + "region1DepthName", + "region2DepthName", + "region3DepthName", + "roadName" + ) + + shouldThrow { + ShopAddress.of( + cityCode, + coordinates, + address + ) + } shouldHaveMessage "올바른 지역 코드가 아닙니다. (행정동 지역 코드는 10자리)" + } +} diff --git a/mealkitary-domain/src/test/kotlin/com/mealkitary/shop/domain/shop/factory/ShopFactoryTest.kt b/mealkitary-domain/src/test/kotlin/com/mealkitary/shop/domain/shop/factory/ShopFactoryTest.kt index 34a8382..709e531 100644 --- a/mealkitary-domain/src/test/kotlin/com/mealkitary/shop/domain/shop/factory/ShopFactoryTest.kt +++ b/mealkitary-domain/src/test/kotlin/com/mealkitary/shop/domain/shop/factory/ShopFactoryTest.kt @@ -1,5 +1,8 @@ package com.mealkitary.shop.domain.shop.factory +import com.mealkitary.common.model.Address +import com.mealkitary.common.model.Coordinates +import com.mealkitary.shop.domain.shop.address.ShopAddress import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.AnnotationSpec import io.kotest.matchers.shouldBe @@ -10,31 +13,66 @@ import io.mockk.mockk class ShopFactoryTest : AnnotationSpec() { private val shopBusinessNumberValidator = mockk() - private val shopFactory = ShopFactory(shopBusinessNumberValidator) + private val addressResolver = mockk() + private val shopFactory = ShopFactory(shopBusinessNumberValidator, addressResolver) @Test fun `사업자번호가 유효하지 않으면 예외를 발생한다`() { shouldThrow { - shopFactory.createOne("집밥뚝딱 안양점", "32-12-3221") + shopFactory.createOne("집밥뚝딱 안양점", "32-12-3221", "경기도 안양시 동안구 벌말로 40") } shouldHaveMessage "올바른 사업자번호 형식이 아닙니다." } @Test - fun `실제로 유효한 사업자번호와 가게이름이라면 가게를 생성한다`() { + fun `실제로 유효한 사업자번호와 가게이름, 주소라면 가게를 생성한다`() { + val expectedShopAddress = + ShopAddress.of("1234567890", Coordinates.of(0.0, 0.0), Address.of("경기도", "안양시 동안구", "벌말로", "40")) + every { shopBusinessNumberValidator.validate(any()) } answers { } + every { addressResolver.resolveAddress("경기도 안양시 동안구 벌말로 40") } returns expectedShopAddress - val shop = shopFactory.createOne("집밥뚝딱 안양점", "321-23-12345") + val shop = shopFactory.createOne("집밥뚝딱 안양점", "321-23-12345", "경기도 안양시 동안구 벌말로 40") shop.title.value shouldBe "집밥뚝딱 안양점" shop.businessNumber.value shouldBe "321-23-12345" + shop.address shouldBe expectedShopAddress } @Test fun `가게 이름이 유효하지 않으면 예외를 발생한다`() { + val expectedShopAddress = + ShopAddress.of("1234567890", Coordinates.of(0.0, 0.0), Address.of("경기도", "안양시 동안구", "벌말로", "40")) + + every { shopBusinessNumberValidator.validate(any()) } answers { } + every { addressResolver.resolveAddress("경기도 안양시 동안구 벌말로 40") } returns expectedShopAddress + + shouldThrow { + shopFactory.createOne("집밥뚝딱 ! 안양점", "321-23-12345", "경기도 안양시 동안구 벌말로 40") + } shouldHaveMessage "올바른 가게 이름 형식이 아닙니다.(한글, 영문, 공백, 숫자만 포함 가능)" + } + + @Test + fun `도로명 주소를 받아 주소 객체를 생선한다`() { + val address = "경기도 안양시 동안구 벌말로" + val shopAddress = ShopAddress.of( + "1234567890", + Coordinates.of( + 127.0, + 40.0 + ), + Address.of( + "경기도", + "안양시", + "동안구", + "벌말로" + ) + ) + every { shopBusinessNumberValidator.validate(any()) } answers { } + every { addressResolver.resolveAddress(address) } answers { shopAddress } shouldThrow { - shopFactory.createOne("집밥뚝딱 ! 안양점", "321-23-12345") + shopFactory.createOne("집밥뚝딱 ! 안양점", "321-23-12345", "경기도 안양시 동안구 벌말로 40") } shouldHaveMessage "올바른 가게 이름 형식이 아닙니다.(한글, 영문, 공백, 숫자만 포함 가능)" } } diff --git a/mealkitary-domain/src/testFixtures/kotlin/data/ShopTestData.kt b/mealkitary-domain/src/testFixtures/kotlin/data/ShopTestData.kt index 1389d2a..968438f 100644 --- a/mealkitary-domain/src/testFixtures/kotlin/data/ShopTestData.kt +++ b/mealkitary-domain/src/testFixtures/kotlin/data/ShopTestData.kt @@ -1,10 +1,13 @@ package data +import com.mealkitary.common.model.Address +import com.mealkitary.common.model.Coordinates import com.mealkitary.shop.domain.product.Product import com.mealkitary.shop.domain.shop.Shop import com.mealkitary.shop.domain.shop.ShopBusinessNumber import com.mealkitary.shop.domain.shop.ShopStatus import com.mealkitary.shop.domain.shop.ShopTitle +import com.mealkitary.shop.domain.shop.address.ShopAddress import data.ProductTestData.Companion.defaultProduct import java.time.LocalTime @@ -22,7 +25,20 @@ class ShopTestData { defaultProduct().withId(1L).withName("부대찌개").build(), defaultProduct().withId(2L).withName("닭볶음탕").build() ), - private var shopBusinessNumber: ShopBusinessNumber = ShopBusinessNumber.from("123-45-67890") + private var shopBusinessNumber: ShopBusinessNumber = ShopBusinessNumber.from("123-45-67890"), + private var shopAddress: ShopAddress = ShopAddress.of( + "1234567890", + Coordinates.of( + 126.99599512792346, + 35.976749396987046 + ), + Address.of( + "region1DepthName", + "region2DepthName", + "region3DepthName", + "roadName" + ) + ) ) { fun withTitle(title: String): ShopBuilder { @@ -50,11 +66,17 @@ class ShopTestData { return this } + fun withAddress(shopAddress: ShopAddress): ShopBuilder { + this.shopAddress = shopAddress + return this + } + fun build(): Shop { return Shop( ShopTitle.from(this.title), this.shopStatus, this.shopBusinessNumber, + this.shopAddress, this.reservableTimes.toMutableList(), this.products.toMutableList() ) diff --git a/mealkitary-infrastructure/adapter-address-resolver/build.gradle.kts b/mealkitary-infrastructure/adapter-address-resolver/build.gradle.kts new file mode 100644 index 0000000..fb0ef72 --- /dev/null +++ b/mealkitary-infrastructure/adapter-address-resolver/build.gradle.kts @@ -0,0 +1,4 @@ +dependencies { + implementation(project(":mealkitary-domain")) + testImplementation(testFixtures(project(":mealkitary-domain"))) +} diff --git a/mealkitary-infrastructure/adapter-address-resolver/src/main/kotlin/com/mealkitary/address/SimpleAddressResolver.kt b/mealkitary-infrastructure/adapter-address-resolver/src/main/kotlin/com/mealkitary/address/SimpleAddressResolver.kt new file mode 100644 index 0000000..f15e509 --- /dev/null +++ b/mealkitary-infrastructure/adapter-address-resolver/src/main/kotlin/com/mealkitary/address/SimpleAddressResolver.kt @@ -0,0 +1,41 @@ +package com.mealkitary.address + +import com.mealkitary.common.model.Address +import com.mealkitary.common.model.Coordinates +import com.mealkitary.shop.domain.shop.address.ShopAddress +import com.mealkitary.shop.domain.shop.factory.AddressResolver +import org.springframework.stereotype.Component + +private const val ADDRESS_MIN_LENGTH = 2 + +@Component +class SimpleAddressResolver : AddressResolver { + + override fun resolveAddress(address: String): ShopAddress { + val value = address.split(" ") + + if (value.size < ADDRESS_MIN_LENGTH) { + throw IllegalArgumentException("주소 형식이 올바르지 않습니다.") + } + + val region1DepthName = value[0] + val region2DepthName = value[1] + val region3DepthName = value.getOrNull(2) ?: "" + val roadName = value.getOrNull(3) ?: "" + + // TODO: 좌표 및 지역 코드를 카카오 API에서 받아올 예정 + return ShopAddress.of( + "1234567890", + Coordinates.of( + 127.0, + 40.0 + ), + Address.of( + region1DepthName, + region2DepthName, + region3DepthName, + roadName + ) + ) + } +} diff --git a/mealkitary-infrastructure/adapter-address-resolver/src/test/kotlin/SimpleAddressResolverTest.kt b/mealkitary-infrastructure/adapter-address-resolver/src/test/kotlin/SimpleAddressResolverTest.kt new file mode 100644 index 0000000..79a6e3e --- /dev/null +++ b/mealkitary-infrastructure/adapter-address-resolver/src/test/kotlin/SimpleAddressResolverTest.kt @@ -0,0 +1,69 @@ +package com.mealkitary.address + +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 org.springframework.stereotype.Component + +@Component +class SimpleAddressResolverTest : AnnotationSpec() { + + @Test + fun `adapter unit test - 문자열 주소값을 받아 주소 객체를 생성한다`() { + val address = "서울특별시 강남구 역삼동 논현로" + val resolver = SimpleAddressResolver() + + val shopAddress = resolver.resolveAddress(address) + + shopAddress.cityCode shouldBe "1234567890" + shopAddress.coordinates.longitude shouldBe 127.0 + shopAddress.coordinates.latitude shouldBe 40.0 + shopAddress.address.region1DepthName shouldBe "서울특별시" + shopAddress.address.region2DepthName shouldBe "강남구" + shopAddress.address.region3DepthName shouldBe "역삼동" + shopAddress.address.roadName shouldBe "논현로" + } + + @Test + fun `adapter unit test - 문자열 주소값이 3등분일 경우 3개의 정보를 가진 주소 객체를 생성한다`() { + val address = "경기도 남양주시 다산동" + val resolver = SimpleAddressResolver() + + val shopAddress = resolver.resolveAddress(address) + + shopAddress.cityCode shouldBe "1234567890" + shopAddress.coordinates.longitude shouldBe 127.0 + shopAddress.coordinates.latitude shouldBe 40.0 + shopAddress.address.region1DepthName shouldBe "경기도" + shopAddress.address.region2DepthName shouldBe "남양주시" + shopAddress.address.region3DepthName shouldBe "다산동" + shopAddress.address.roadName shouldBe "" + } + + @Test + fun `adapter unit test - 문자열 주소값이 2등분일 경우 2개의 정보를 가진 주소 객체를 생성한다`() { + val address = "제주특별자치도 한림읍" + val resolver = SimpleAddressResolver() + + val shopAddress = resolver.resolveAddress(address) + + shopAddress.cityCode shouldBe "1234567890" + shopAddress.coordinates.longitude shouldBe 127.0 + shopAddress.coordinates.latitude shouldBe 40.0 + shopAddress.address.region1DepthName shouldBe "제주특별자치도" + shopAddress.address.region2DepthName shouldBe "한림읍" + shopAddress.address.region3DepthName shouldBe "" + shopAddress.address.roadName shouldBe "" + } + + @Test + fun `adapter unit test - 문자열 주소값이 2등분 이하일 경우 예외를 발생한다`() { + val address = "제주특별자치도" + val resolver = SimpleAddressResolver() + + shouldThrow { + resolver.resolveAddress(address) + } shouldHaveMessage "주소 형식이 올바르지 않습니다." + } +} diff --git a/mealkitary-infrastructure/adapter-business-registration-number-validator/open-api-brn-validator/build.gradle.kts b/mealkitary-infrastructure/adapter-business-registration-number-validator/open-api-brn-validator/build.gradle.kts new file mode 100644 index 0000000..a989f72 --- /dev/null +++ b/mealkitary-infrastructure/adapter-business-registration-number-validator/open-api-brn-validator/build.gradle.kts @@ -0,0 +1,7 @@ +dependencies { + val mockWebServerVersion: String by properties + implementation("org.springframework.boot:spring-boot-starter-webflux") + implementation(project(":mealkitary-domain")) + testImplementation("com.squareup.okhttp3:mockwebserver:$mockWebServerVersion") + testImplementation("io.projectreactor:reactor-test") +} diff --git a/mealkitary-infrastructure/adapter-business-registration-number-validator/open-api-brn-validator/src/main/kotlin/com/mealkitary/brn/OpenApiBrnValidator.kt b/mealkitary-infrastructure/adapter-business-registration-number-validator/open-api-brn-validator/src/main/kotlin/com/mealkitary/brn/OpenApiBrnValidator.kt new file mode 100644 index 0000000..25acef2 --- /dev/null +++ b/mealkitary-infrastructure/adapter-business-registration-number-validator/open-api-brn-validator/src/main/kotlin/com/mealkitary/brn/OpenApiBrnValidator.kt @@ -0,0 +1,42 @@ +package com.mealkitary.brn + +import com.mealkitary.brn.payload.OpenApiBrnStatusPayload +import com.mealkitary.brn.payload.OpenApiBrnStatusResponse +import com.mealkitary.shop.domain.shop.ShopBusinessNumber +import com.mealkitary.shop.domain.shop.factory.ShopBusinessNumberValidator +import org.springframework.context.annotation.Primary +import org.springframework.stereotype.Component + +@Primary +@Component +class OpenApiBrnValidator( + private val openApiWebClient: OpenApiWebClient +) : ShopBusinessNumberValidator { + + override fun validate(brn: ShopBusinessNumber) { + val brnValue = removeDelimiter(brn.value) + + val openApiStatusResponse = openApiWebClient.requestStatus( + OpenApiBrnStatusPayload(listOf(brnValue)), + "https://api.odcloud.kr/api/nts-businessman" + ) + + checkHasResult(openApiStatusResponse) + checkBrnStatus(openApiStatusResponse) + } + + private fun removeDelimiter(brn: String) = brn.replace("-", "") + + private fun checkHasResult(openApiBrnStatusResponse: OpenApiBrnStatusResponse) { + if (openApiBrnStatusResponse.data.isEmpty()) { + throw IllegalArgumentException("사업자 번호 조회 결과가 없습니다.") + } + } + + private fun checkBrnStatus(openApiBrnStatusResponse: OpenApiBrnStatusResponse) { + val openApiBrnStatus = openApiBrnStatusResponse.data[0] + if (openApiBrnStatus.b_stt != "계속사업자") { + throw IllegalArgumentException("유효하지 않은 사업자 번호입니다.") + } + } +} diff --git a/mealkitary-infrastructure/adapter-business-registration-number-validator/open-api-brn-validator/src/main/kotlin/com/mealkitary/brn/OpenApiWebClient.kt b/mealkitary-infrastructure/adapter-business-registration-number-validator/open-api-brn-validator/src/main/kotlin/com/mealkitary/brn/OpenApiWebClient.kt new file mode 100644 index 0000000..e6caee2 --- /dev/null +++ b/mealkitary-infrastructure/adapter-business-registration-number-validator/open-api-brn-validator/src/main/kotlin/com/mealkitary/brn/OpenApiWebClient.kt @@ -0,0 +1,44 @@ +package com.mealkitary.brn + +import com.mealkitary.brn.payload.OpenApiBrnStatusPayload +import com.mealkitary.brn.payload.OpenApiBrnStatusResponse +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.stereotype.Component +import org.springframework.web.reactive.function.client.WebClient +import reactor.core.publisher.Mono +import java.nio.charset.StandardCharsets + +@Component +class OpenApiWebClient( + private val webClient: WebClient, + @Value("\${openapi.brn.serviceKey}") + private val serviceKey: String +) { + + fun requestStatus(payload: OpenApiBrnStatusPayload, baseUrl: String): OpenApiBrnStatusResponse { + val openApiBrnStatusResponse = webClient.post() + .uri("$baseUrl/v1/status?serviceKey=$serviceKey") + .headers { setHeader(it) } + .body(Mono.just(payload), OpenApiBrnStatusPayload::class.java) + .exchangeToMono { + if (it.statusCode() == HttpStatus.OK) { + it.bodyToMono(OpenApiBrnStatusResponse::class.java) + } else { + Mono.error(RuntimeException()) + } + } + .block() + + return openApiBrnStatusResponse!! + } + + private fun setHeader( + headers: HttpHeaders, + ) { + headers.acceptCharset = listOf(StandardCharsets.UTF_8) + headers.contentType = MediaType.APPLICATION_JSON + } +} diff --git a/mealkitary-infrastructure/adapter-business-registration-number-validator/open-api-brn-validator/src/main/kotlin/com/mealkitary/brn/payload/OpenApiBrnStatus.kt b/mealkitary-infrastructure/adapter-business-registration-number-validator/open-api-brn-validator/src/main/kotlin/com/mealkitary/brn/payload/OpenApiBrnStatus.kt new file mode 100644 index 0000000..df4a5f2 --- /dev/null +++ b/mealkitary-infrastructure/adapter-business-registration-number-validator/open-api-brn-validator/src/main/kotlin/com/mealkitary/brn/payload/OpenApiBrnStatus.kt @@ -0,0 +1,15 @@ +package com.mealkitary.brn.payload + +class OpenApiBrnStatus( + val b_no: String, + val b_stt: String, + val b_stt_cd: String, + val tax_type: String, + val tax_type_cd: String, + val end_dt: String, + val utcc_yn: String, + val tax_type_change_dt: String, + val invoice_apply_dt: String, + val rbf_tax_type: String, + val rbf_tax_type_cd: String +) diff --git a/mealkitary-infrastructure/adapter-business-registration-number-validator/open-api-brn-validator/src/main/kotlin/com/mealkitary/brn/payload/OpenApiBrnStatusPayload.kt b/mealkitary-infrastructure/adapter-business-registration-number-validator/open-api-brn-validator/src/main/kotlin/com/mealkitary/brn/payload/OpenApiBrnStatusPayload.kt new file mode 100644 index 0000000..a9d3665 --- /dev/null +++ b/mealkitary-infrastructure/adapter-business-registration-number-validator/open-api-brn-validator/src/main/kotlin/com/mealkitary/brn/payload/OpenApiBrnStatusPayload.kt @@ -0,0 +1,5 @@ +package com.mealkitary.brn.payload + +class OpenApiBrnStatusPayload( + val b_no: List +) diff --git a/mealkitary-infrastructure/adapter-business-registration-number-validator/open-api-brn-validator/src/main/kotlin/com/mealkitary/brn/payload/OpenApiBrnStatusResponse.kt b/mealkitary-infrastructure/adapter-business-registration-number-validator/open-api-brn-validator/src/main/kotlin/com/mealkitary/brn/payload/OpenApiBrnStatusResponse.kt new file mode 100644 index 0000000..4ad312b --- /dev/null +++ b/mealkitary-infrastructure/adapter-business-registration-number-validator/open-api-brn-validator/src/main/kotlin/com/mealkitary/brn/payload/OpenApiBrnStatusResponse.kt @@ -0,0 +1,8 @@ +package com.mealkitary.brn.payload + +class OpenApiBrnStatusResponse( + val status_code: String, + val match_cnt: Int, + val request_cnt: Int, + val data: List +) diff --git a/mealkitary-infrastructure/adapter-business-registration-number-validator/open-api-brn-validator/src/test/kotlin/com/mealkitary/brn/OpenApiBrnValidatorTest.kt b/mealkitary-infrastructure/adapter-business-registration-number-validator/open-api-brn-validator/src/test/kotlin/com/mealkitary/brn/OpenApiBrnValidatorTest.kt new file mode 100644 index 0000000..5b73ae9 --- /dev/null +++ b/mealkitary-infrastructure/adapter-business-registration-number-validator/open-api-brn-validator/src/test/kotlin/com/mealkitary/brn/OpenApiBrnValidatorTest.kt @@ -0,0 +1,82 @@ +package com.mealkitary.brn + +import com.mealkitary.brn.payload.OpenApiBrnStatus +import com.mealkitary.brn.payload.OpenApiBrnStatusPayload +import com.mealkitary.brn.payload.OpenApiBrnStatusResponse +import com.mealkitary.shop.domain.shop.ShopBusinessNumber +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.slot +import io.mockk.verify + +class OpenApiBrnValidatorTest : AnnotationSpec() { + + private val openApiWebClient = mockk() + private val openApiBrnValidator = OpenApiBrnValidator(openApiWebClient) + + @Test + fun `Open API 호출할 때, 사업자 등록 번호의 하이픈(-)을 제거한다`() { + val payloadSlot = slot() + every { openApiWebClient.requestStatus(capture(payloadSlot), any()) } answers { + createResponse("계속사업자") + } + + openApiBrnValidator.validate(ShopBusinessNumber.from("123-12-12345")) + + val captured = payloadSlot.captured + captured.b_no.get(0) shouldBe "1231212345" + } + + @Test + fun `사업자 번호 조회 결과가 존재하지 않는다면 예외를 발생한다`() { + every { openApiWebClient.requestStatus(any(), any()) } answers { + OpenApiBrnStatusResponse( + "OK", + 0, + 0, + emptyList() + ) + } + + shouldThrow { + openApiBrnValidator.validate(ShopBusinessNumber.from("123-12-12345")) + } shouldHaveMessage "사업자 번호 조회 결과가 없습니다." + } + + @Test + fun `사업자 상태가 계속사업자가 아니라면 예외를 발생한다`() { + every { openApiWebClient.requestStatus(any(), any()) } answers { + createResponse("휴업자") + } + + shouldThrow { + openApiBrnValidator.validate(ShopBusinessNumber.from("123-12-12345")) + } shouldHaveMessage "유효하지 않은 사업자 번호입니다." + } + + @Test + fun `HTTP 클라이언트에게 Open API 경로를 전달한다`() { + every { openApiWebClient.requestStatus(any(), any()) } answers { + createResponse("계속사업자") + } + + openApiBrnValidator.validate(ShopBusinessNumber.from("123-12-12345")) + + verify { openApiWebClient.requestStatus(any(), "https://api.odcloud.kr/api/nts-businessman") } + } + + private fun createResponse(b_stt: String) = OpenApiBrnStatusResponse( + "OK", + 0, + 0, + listOf( + OpenApiBrnStatus( + "", b_stt, "", "", "", "", "", "", "", "", "" + ) + ) + ) +} diff --git a/mealkitary-infrastructure/adapter-business-registration-number-validator/open-api-brn-validator/src/test/kotlin/com/mealkitary/brn/OpenApiWebClientTest.kt b/mealkitary-infrastructure/adapter-business-registration-number-validator/open-api-brn-validator/src/test/kotlin/com/mealkitary/brn/OpenApiWebClientTest.kt new file mode 100644 index 0000000..4011ea6 --- /dev/null +++ b/mealkitary-infrastructure/adapter-business-registration-number-validator/open-api-brn-validator/src/test/kotlin/com/mealkitary/brn/OpenApiWebClientTest.kt @@ -0,0 +1,94 @@ +package com.mealkitary.brn + +import com.fasterxml.jackson.databind.ObjectMapper +import com.mealkitary.brn.payload.OpenApiBrnStatus +import com.mealkitary.brn.payload.OpenApiBrnStatusPayload +import com.mealkitary.brn.payload.OpenApiBrnStatusResponse +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.AnnotationSpec +import io.kotest.inspectors.forAll +import io.kotest.matchers.shouldBe +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.web.reactive.function.client.WebClient + +class OpenApiWebClientTest : AnnotationSpec() { + + private lateinit var mockWebServer: MockWebServer + private lateinit var webClient: WebClient + private lateinit var openApiWebClient: OpenApiWebClient + private val objectMapper = ObjectMapper() + + @BeforeEach + fun setUp() { + mockWebServer = MockWebServer() + mockWebServer.start() + webClient = WebClient.builder() + .baseUrl(mockWebServer.url("").toString()) + .codecs { configurer -> + configurer.defaultCodecs().maxInMemorySize(5 * 1024 * 1024) + } + .defaultHeaders { headers -> + headers.set(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + headers.set(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + } + .build() + + openApiWebClient = OpenApiWebClient(webClient, "serviceKey") + } + + @AfterEach + fun teardown() { + mockWebServer.shutdown() + } + + @Test + fun `200 OK를 받으면 아무 예외도 발생하지 않는다`() { + val expectedPath = "/v1/status?serviceKey=serviceKey" + mockWebServer.enqueue( + MockResponse() + .setBody(objectMapper.writeValueAsString(createResponse())) + .setResponseCode(200) + .addHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON) + ) + + openApiWebClient.requestStatus(OpenApiBrnStatusPayload(listOf("1232323456")), mockWebServer.url("").toString()) + + val recordedRequest = mockWebServer.takeRequest() + recordedRequest.path shouldBe expectedPath + recordedRequest.method shouldBe "POST" + recordedRequest.body.toString().contains("1232323456") + } + + @Test + fun `200이 아닌 코드는 RuntimeException으로 처리한다`() { + listOf(300, 400, 401, 500).forAll { + mockWebServer.enqueue( + MockResponse() + .setResponseCode(it) + .setBody(objectMapper.writeValueAsString(createResponse())) + .addHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON) + ) + + shouldThrow { + openApiWebClient.requestStatus( + OpenApiBrnStatusPayload(listOf("1232323456")), + mockWebServer.url("").toString() + ) + } + } + } + + private fun createResponse() = OpenApiBrnStatusResponse( + "OK", + 0, + 0, + listOf( + OpenApiBrnStatus( + "", "계속사업자", "", "", "", "", "", "", "", "", "" + ) + ) + ) +} diff --git a/mealkitary-infrastructure/business-registration-number-validator/adapter-open-api-brn-validator/build.gradle.kts b/mealkitary-infrastructure/adapter-business-registration-number-validator/simple-brn-validator/build.gradle.kts similarity index 100% rename from mealkitary-infrastructure/business-registration-number-validator/adapter-open-api-brn-validator/build.gradle.kts rename to mealkitary-infrastructure/adapter-business-registration-number-validator/simple-brn-validator/build.gradle.kts diff --git a/mealkitary-infrastructure/business-registration-number-validator/adapter-simple-brn-validator/src/main/kotlin/com/mealkitary/brn/SimpleBrnValidator.kt b/mealkitary-infrastructure/adapter-business-registration-number-validator/simple-brn-validator/src/main/kotlin/com/mealkitary/brn/SimpleBrnValidator.kt similarity index 84% rename from mealkitary-infrastructure/business-registration-number-validator/adapter-simple-brn-validator/src/main/kotlin/com/mealkitary/brn/SimpleBrnValidator.kt rename to mealkitary-infrastructure/adapter-business-registration-number-validator/simple-brn-validator/src/main/kotlin/com/mealkitary/brn/SimpleBrnValidator.kt index 8010671..8417d87 100644 --- a/mealkitary-infrastructure/business-registration-number-validator/adapter-simple-brn-validator/src/main/kotlin/com/mealkitary/brn/SimpleBrnValidator.kt +++ b/mealkitary-infrastructure/adapter-business-registration-number-validator/simple-brn-validator/src/main/kotlin/com/mealkitary/brn/SimpleBrnValidator.kt @@ -2,11 +2,9 @@ package com.mealkitary.brn import com.mealkitary.shop.domain.shop.ShopBusinessNumber import com.mealkitary.shop.domain.shop.factory.ShopBusinessNumberValidator -import org.springframework.context.annotation.Primary import org.springframework.stereotype.Component @Component -@Primary class SimpleBrnValidator : ShopBusinessNumberValidator { override fun validate(brn: ShopBusinessNumber) {} diff --git a/mealkitary-infrastructure/business-registration-number-validator/adapter-simple-brn-validator/src/test/kotlin/com/mealkitary/brn/SimpleBrnValidatorTest.kt b/mealkitary-infrastructure/adapter-business-registration-number-validator/simple-brn-validator/src/test/kotlin/com/mealkitary/brn/SimpleBrnValidatorTest.kt similarity index 100% rename from mealkitary-infrastructure/business-registration-number-validator/adapter-simple-brn-validator/src/test/kotlin/com/mealkitary/brn/SimpleBrnValidatorTest.kt rename to mealkitary-infrastructure/adapter-business-registration-number-validator/simple-brn-validator/src/test/kotlin/com/mealkitary/brn/SimpleBrnValidatorTest.kt diff --git a/mealkitary-infrastructure/adapter-configuration/build.gradle.kts b/mealkitary-infrastructure/adapter-configuration/build.gradle.kts new file mode 100644 index 0000000..044d087 --- /dev/null +++ b/mealkitary-infrastructure/adapter-configuration/build.gradle.kts @@ -0,0 +1,3 @@ +dependencies { + implementation("org.springframework.boot:spring-boot-starter-webflux") +} diff --git a/mealkitary-infrastructure/adapter-paymentgateway-tosspayments/src/main/kotlin/com/mealkitary/paymentgateway/WebClientConfig.kt b/mealkitary-infrastructure/adapter-configuration/src/main/kotlin/com/mealkitary/config/WebClientConfig.kt similarity index 78% rename from mealkitary-infrastructure/adapter-paymentgateway-tosspayments/src/main/kotlin/com/mealkitary/paymentgateway/WebClientConfig.kt rename to mealkitary-infrastructure/adapter-configuration/src/main/kotlin/com/mealkitary/config/WebClientConfig.kt index 77c87d4..0f0efe3 100644 --- a/mealkitary-infrastructure/adapter-paymentgateway-tosspayments/src/main/kotlin/com/mealkitary/paymentgateway/WebClientConfig.kt +++ b/mealkitary-infrastructure/adapter-configuration/src/main/kotlin/com/mealkitary/config/WebClientConfig.kt @@ -1,4 +1,4 @@ -package com.mealkitary.paymentgateway +package com.mealkitary.config import io.netty.channel.ChannelOption import io.netty.handler.timeout.ReadTimeoutHandler @@ -8,6 +8,8 @@ import org.springframework.context.annotation.Configuration import org.springframework.http.client.reactive.ReactorClientHttpConnector import org.springframework.web.reactive.function.client.ExchangeStrategies import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.util.DefaultUriBuilderFactory +import org.springframework.web.util.UriBuilderFactory import reactor.netty.http.client.HttpClient import java.time.Duration import java.util.concurrent.TimeUnit @@ -19,10 +21,24 @@ class WebClientConfig { @Bean fun webClient() = WebClient.builder() + .uriBuilderFactory(uriBuilderFactory()) .exchangeStrategies(exchangeStrategies()) .clientConnector(ReactorClientHttpConnector(httpClient())) .build() + private fun uriBuilderFactory(): UriBuilderFactory { + val factory = DefaultUriBuilderFactory() + factory.encodingMode = DefaultUriBuilderFactory.EncodingMode.NONE + + return factory + } + + private fun exchangeStrategies() = ExchangeStrategies.builder() + .codecs { configurer -> + configurer.defaultCodecs().maxInMemorySize(TEEN_MEGA_BYTE) + } + .build() + private fun httpClient() = HttpClient.create() .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000) .responseTimeout(Duration.ofMillis(5000)) @@ -31,10 +47,4 @@ class WebClientConfig { .addHandlerLast(ReadTimeoutHandler(5000, TimeUnit.MILLISECONDS)) .addHandlerLast(WriteTimeoutHandler(5000, TimeUnit.MILLISECONDS)) } - - private fun exchangeStrategies() = ExchangeStrategies.builder() - .codecs { configurer -> - configurer.defaultCodecs().maxInMemorySize(TEEN_MEGA_BYTE) - } - .build() } diff --git a/mealkitary-infrastructure/adapter-persistence-spring-data-jpa/src/main/resources/data.sql b/mealkitary-infrastructure/adapter-persistence-spring-data-jpa/src/main/resources/data.sql index 929806b..ab9372a 100644 --- a/mealkitary-infrastructure/adapter-persistence-spring-data-jpa/src/main/resources/data.sql +++ b/mealkitary-infrastructure/adapter-persistence-spring-data-jpa/src/main/resources/data.sql @@ -1,5 +1,5 @@ insert into shop -values (1, '123-12-12345', 'VALID', '집밥뚝딱 철산점'); +values (1, '경기도', '광명시', '철산동', '철산로', '1234567890', 30.03, 50.05, '123-12-12345', 'VALID', '집밥뚝딱 철산점'); insert into product values (1, '부대찌개', 15800, 1); insert into product @@ -17,7 +17,7 @@ values (1, '18:30'); insert into shop -values (2, '123-12-12345', 'VALID', '집밥뚝딱 안양점'); +values (2, '경기도', '안양시', '동안구', '경수대로', '1234567890', 30.03, 50.05, '123-12-12345', 'VALID', '집밥뚝딱 안양점'); insert into product values (4, '비비고 만두', 3200, 2); insert into product @@ -30,7 +30,7 @@ insert into reservable_time values (2, '19:30'); insert into shop -values (3, '123-12-12345', 'VALID', '집밥뚝딱 숭실대입구점'); +values (3, '서울시', '동작구', '상도동', '', '1234567890', 30.03, 50.05, '123-12-12345', 'VALID', '집밥뚝딱 숭실대입구점'); insert into product values (7, '왕만두', 4900, 3); insert into product @@ -48,7 +48,7 @@ values (3, '23:30'); insert into shop -values (4, '123-12-12345', 'VALID', '집밥뚝딱 다산점'); +values (4, '경기도', '남양주시', '다산동', '', '1234567890', 30.03, 50.05, '123-12-12345', 'VALID', '집밥뚝딱 다산점'); insert into product values (10, '김치찌개', 15800, 4); insert into reservable_time 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 f6b6d25..1bdfea1 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 @@ -4,6 +4,7 @@ import com.mealkitary.reservation.application.service.AcceptReservationService import com.mealkitary.reservation.application.service.PayReservationService import com.mealkitary.reservation.application.service.RejectReservationService import com.mealkitary.shop.application.service.RegisterShopService +import com.mealkitary.shop.domain.shop.factory.ShopFactory import com.ninjasquad.springmockk.MockkBean import io.kotest.core.spec.style.AnnotationSpec import io.kotest.extensions.spring.SpringExtension @@ -36,4 +37,7 @@ abstract class PersistenceIntegrationTestSupport : AnnotationSpec() { @MockkBean private lateinit var registerShopService: RegisterShopService + + @MockkBean + private lateinit var shopFactory: ShopFactory } diff --git a/mealkitary-infrastructure/business-registration-number-validator/adapter-simple-brn-validator/build.gradle.kts b/mealkitary-infrastructure/business-registration-number-validator/adapter-simple-brn-validator/build.gradle.kts deleted file mode 100644 index c685d19..0000000 --- a/mealkitary-infrastructure/business-registration-number-validator/adapter-simple-brn-validator/build.gradle.kts +++ /dev/null @@ -1,3 +0,0 @@ -dependencies { - implementation(project(":mealkitary-domain")) -} diff --git a/settings.gradle.kts b/settings.gradle.kts index 52db22b..fdb87a7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -7,8 +7,10 @@ include( "mealkitary-infrastructure:adapter-persistence-spring-data-jpa", "mealkitary-infrastructure:adapter-paymentgateway-tosspayments", "mealkitary-infrastructure:adapter-firebase-notification", - "mealkitary-infrastructure:business-registration-number-validator:adapter-open-api-brn-validator", - "mealkitary-infrastructure:business-registration-number-validator:adapter-simple-brn-validator" + "mealkitary-infrastructure:adapter-configuration", + "mealkitary-infrastructure:adapter-business-registration-number-validator:open-api-brn-validator", + "mealkitary-infrastructure:adapter-business-registration-number-validator:simple-brn-validator", + "mealkitary-infrastructure:adapter-address-resolver" ) pluginManagement {