diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0a642d..ab08419 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,10 @@ on: branches: [ "main", "develop" ] pull_request: +permissions: + contents: read + pull-requests: read + jobs: detect-changes: runs-on: ubuntu-latest diff --git a/build.gradle.kts b/build.gradle.kts index ec2de55..4219ac7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -29,7 +29,7 @@ subprojects { dependencies { - add("testRuntimeOnly", "org.junit.platform:junit-platform-launcher:1.13.3") + add("testRuntimeOnly", "org.junit.platform:junit-platform-launcher") add("testImplementation", "io.mockk:mockk:1.13.13") } } diff --git a/payment/services/api/build.gradle.kts b/payment/services/api/build.gradle.kts new file mode 100644 index 0000000..2e34ea4 --- /dev/null +++ b/payment/services/api/build.gradle.kts @@ -0,0 +1,31 @@ +plugins { + id("io.spring.dependency-management") + id("org.springframework.boot") + kotlin("jvm") + kotlin("plugin.spring") + kotlin("plugin.jpa") +} + +dependencies { + implementation(project(":voucher-adapter")) + + + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("com.h2database:h2") + testImplementation("io.rest-assured:rest-assured") + + // 테스트에서 도메인 클래스 접근을 위해 필요 + testImplementation(project(":voucher-domain")) + testImplementation(project(":voucher-application")) + + + runtimeOnly("com.mysql:mysql-connector-j") + +} + +tasks.withType { + useJUnitPlatform() +} \ No newline at end of file diff --git a/payment/services/api/src/main/kotlin/app/payment/PaymentApiApplication.kt b/payment/services/api/src/main/kotlin/app/payment/PaymentApiApplication.kt new file mode 100644 index 0000000..c362ae4 --- /dev/null +++ b/payment/services/api/src/main/kotlin/app/payment/PaymentApiApplication.kt @@ -0,0 +1,11 @@ +package app.payment + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication + +@SpringBootApplication(scanBasePackages = ["app.payment"]) +class PaymentApiApplication + +fun main(args: Array) { + runApplication(*args) +} \ No newline at end of file diff --git a/payment/services/api/src/test/resources/application-test.yml b/payment/services/api/src/test/resources/application-test.yml new file mode 100644 index 0000000..2bfa805 --- /dev/null +++ b/payment/services/api/src/test/resources/application-test.yml @@ -0,0 +1,25 @@ +spring: + datasource: + url: jdbc:h2:mem:testdb;MODE=MySQL + driver-class-name: org.h2.Driver + username: sa + password: + + jpa: + hibernate: + ddl-auto: create-drop + properties: + hibernate: + format_sql: true + show_sql: true + dialect: org.hibernate.dialect.H2Dialect + + h2: + console: + enabled: true + +logging: + level: + app.payment: DEBUG + org.springframework.web: DEBUG + org.hibernate.SQL: DEBUG \ No newline at end of file diff --git a/payment/src/main/kotlin/app/payment/domain/voucher/Voucher.kt b/payment/src/main/kotlin/app/payment/domain/voucher/Voucher.kt deleted file mode 100644 index 1b1aa2d..0000000 --- a/payment/src/main/kotlin/app/payment/domain/voucher/Voucher.kt +++ /dev/null @@ -1,12 +0,0 @@ -package app.payment.domain.voucher - -import java.time.OffsetDateTime - -class Voucher( - val id: Long, - val type: VoucherType, - val consumptionType: ConsumptionType, - var status: VoucherStatus, - val createdAt: OffsetDateTime, - var updatedAt: OffsetDateTime, -) diff --git a/payment/src/main/kotlin/app/payment/domain/voucher/VoucherContent.kt b/payment/src/main/kotlin/app/payment/domain/voucher/VoucherContent.kt deleted file mode 100644 index 477cbae..0000000 --- a/payment/src/main/kotlin/app/payment/domain/voucher/VoucherContent.kt +++ /dev/null @@ -1,14 +0,0 @@ -package app.payment.domain.voucher - -import java.time.OffsetDateTime - -class VoucherContent( - val id: Long, - val voucherId: Long, - val version: Int, - val title: String, - val description: String, - val activeFrom: OffsetDateTime, - val activeUntil: OffsetDateTime, - val createdAt: OffsetDateTime, -) diff --git a/payment/voucher/adapter/build.gradle.kts b/payment/voucher/adapter/build.gradle.kts new file mode 100644 index 0000000..fb2cae4 --- /dev/null +++ b/payment/voucher/adapter/build.gradle.kts @@ -0,0 +1,29 @@ +plugins { + kotlin("jvm") + kotlin("plugin.spring") + kotlin("plugin.jpa") + id("io.spring.dependency-management") +} + +dependencies { + implementation(project(":voucher-application")) + implementation(project(":voucher-domain")) + + + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("org.jetbrains.kotlin:kotlin-reflect") + + + // --- test --- + testImplementation("org.springframework.boot:spring-boot-starter-test") { + exclude(group = "org.mockito") + } + testImplementation("com.ninja-squad:springmockk:4.0.2") + +} + +tasks.withType { + useJUnitPlatform() +} \ No newline at end of file diff --git a/payment/voucher/adapter/src/main/kotlin/app/payment/voucher/adapter/inbound/GetVoucherResponse.kt b/payment/voucher/adapter/src/main/kotlin/app/payment/voucher/adapter/inbound/GetVoucherResponse.kt new file mode 100644 index 0000000..a0e1630 --- /dev/null +++ b/payment/voucher/adapter/src/main/kotlin/app/payment/voucher/adapter/inbound/GetVoucherResponse.kt @@ -0,0 +1,8 @@ +package app.payment.voucher.adapter.inbound + +data class GetVoucherResponse( + val id: Long, + val consumptionType: String, + val title: String, + val description: String, +) diff --git a/payment/voucher/adapter/src/main/kotlin/app/payment/voucher/adapter/inbound/GetVouchersResponse.kt b/payment/voucher/adapter/src/main/kotlin/app/payment/voucher/adapter/inbound/GetVouchersResponse.kt new file mode 100644 index 0000000..b17b6c5 --- /dev/null +++ b/payment/voucher/adapter/src/main/kotlin/app/payment/voucher/adapter/inbound/GetVouchersResponse.kt @@ -0,0 +1,22 @@ +package app.payment.voucher.adapter.inbound + +import app.payment.voucher.application.port.inbound.CurrentPublishedVouchers + +data class GetVouchersResponse( + val vouchers: List, +) { + companion object { + fun from(items: List): GetVouchersResponse = + GetVouchersResponse( + vouchers = + items.map { + GetVoucherResponse( + id = it.id, + consumptionType = it.consumptionType.name, + title = it.title, + description = it.description, + ) + }, + ) + } +} diff --git a/payment/voucher/adapter/src/main/kotlin/app/payment/voucher/adapter/inbound/VoucherController.kt b/payment/voucher/adapter/src/main/kotlin/app/payment/voucher/adapter/inbound/VoucherController.kt new file mode 100644 index 0000000..dd6e99f --- /dev/null +++ b/payment/voucher/adapter/src/main/kotlin/app/payment/voucher/adapter/inbound/VoucherController.kt @@ -0,0 +1,20 @@ +package app.payment.voucher.adapter.inbound + +import app.payment.voucher.application.port.inbound.VoucherUseCase +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v1/vouchers") +class VoucherController( + private val voucherUseCase: VoucherUseCase, +) { + @GetMapping + fun getVouchers(): ResponseEntity { + val vouchers = voucherUseCase.getPublishedVouchers() + return ResponseEntity.ok() + .body(GetVouchersResponse.from(vouchers)) + } +} diff --git a/payment/voucher/adapter/src/main/kotlin/app/payment/voucher/adapter/outbound/JpaConfiguration.kt b/payment/voucher/adapter/src/main/kotlin/app/payment/voucher/adapter/outbound/JpaConfiguration.kt new file mode 100644 index 0000000..29ba8d2 --- /dev/null +++ b/payment/voucher/adapter/src/main/kotlin/app/payment/voucher/adapter/outbound/JpaConfiguration.kt @@ -0,0 +1,8 @@ +package app.payment.voucher.adapter.outbound + +import org.springframework.context.annotation.Configuration +import org.springframework.data.jpa.repository.config.EnableJpaAuditing + +@Configuration +@EnableJpaAuditing +class JpaConfiguration diff --git a/payment/voucher/adapter/src/main/kotlin/app/payment/voucher/adapter/outbound/VoucherContentJpaEntity.kt b/payment/voucher/adapter/src/main/kotlin/app/payment/voucher/adapter/outbound/VoucherContentJpaEntity.kt new file mode 100644 index 0000000..3cf643c --- /dev/null +++ b/payment/voucher/adapter/src/main/kotlin/app/payment/voucher/adapter/outbound/VoucherContentJpaEntity.kt @@ -0,0 +1,45 @@ +package app.payment.voucher.adapter.outbound + +import app.payment.voucher.domain.VoucherContent +import jakarta.persistence.Entity +import jakarta.persistence.EntityListeners +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Table +import org.springframework.data.annotation.CreatedDate +import org.springframework.data.jpa.domain.support.AuditingEntityListener +import java.time.OffsetDateTime + +@Entity +@Table(name = "voucher_contents") +@EntityListeners(AuditingEntityListener::class) +class VoucherContentJpaEntity( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + val voucherId: Long, + val version: Int, + val title: String, + val description: String, + val activeFrom: OffsetDateTime, + val activeUntil: OffsetDateTime, + @CreatedDate + var createdAt: OffsetDateTime? = null +) { + fun toDomain(): VoucherContent { + val id = requireNotNull(id) { "VoucherContent.id must not be null" } + val createdAt = requireNotNull(createdAt) { "VoucherContent.createdAt must not be null" } + + return VoucherContent( + id = id, + voucherId = voucherId, + version = version, + title = title, + description = description, + activeFrom = activeFrom, + activeUntil = activeUntil, + createdAt = createdAt + ) + } +} diff --git a/payment/voucher/adapter/src/main/kotlin/app/payment/voucher/adapter/outbound/VoucherContentJpaRepository.kt b/payment/voucher/adapter/src/main/kotlin/app/payment/voucher/adapter/outbound/VoucherContentJpaRepository.kt new file mode 100644 index 0000000..bd0ee27 --- /dev/null +++ b/payment/voucher/adapter/src/main/kotlin/app/payment/voucher/adapter/outbound/VoucherContentJpaRepository.kt @@ -0,0 +1,19 @@ +package app.payment.voucher.adapter.outbound + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import java.time.OffsetDateTime + +interface VoucherContentJpaRepository : JpaRepository { + @Query(""" + SELECT vc FROM VoucherContentJpaEntity vc + WHERE vc.voucherId IN :voucherIds + AND vc.activeFrom <= :now + AND vc.activeUntil >= :now + """) + fun findActiveContentsByVoucherIds( + @Param("voucherIds") voucherIds: List, + @Param("now") now: OffsetDateTime + ): List +} diff --git a/payment/voucher/adapter/src/main/kotlin/app/payment/voucher/adapter/outbound/VoucherContentReaderImpl.kt b/payment/voucher/adapter/src/main/kotlin/app/payment/voucher/adapter/outbound/VoucherContentReaderImpl.kt new file mode 100644 index 0000000..8e77193 --- /dev/null +++ b/payment/voucher/adapter/src/main/kotlin/app/payment/voucher/adapter/outbound/VoucherContentReaderImpl.kt @@ -0,0 +1,20 @@ +package app.payment.voucher.adapter.outbound + +import app.payment.voucher.application.port.outbound.VoucherContentReader +import app.payment.voucher.domain.VoucherContent +import org.springframework.stereotype.Repository +import java.time.OffsetDateTime + +@Repository +class VoucherContentReaderImpl( + private val voucherContentRepository: VoucherContentJpaRepository, +) : VoucherContentReader { + override fun readCurrentContents(publishedVoucherIds: List, now: OffsetDateTime): List { + if (publishedVoucherIds.isEmpty()) { + return emptyList() + } + + return voucherContentRepository.findActiveContentsByVoucherIds(publishedVoucherIds, now) + .map { it.toDomain() } + } +} diff --git a/payment/voucher/adapter/src/main/kotlin/app/payment/voucher/adapter/outbound/VoucherJpaEntity.kt b/payment/voucher/adapter/src/main/kotlin/app/payment/voucher/adapter/outbound/VoucherJpaEntity.kt new file mode 100644 index 0000000..0426f1d --- /dev/null +++ b/payment/voucher/adapter/src/main/kotlin/app/payment/voucher/adapter/outbound/VoucherJpaEntity.kt @@ -0,0 +1,52 @@ +package app.payment.voucher.adapter.outbound + +import app.payment.voucher.domain.ConsumptionType +import app.payment.voucher.domain.Voucher +import app.payment.voucher.domain.VoucherStatus +import app.payment.voucher.domain.VoucherType +import jakarta.persistence.Entity +import jakarta.persistence.EntityListeners +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Table +import org.springframework.data.annotation.CreatedDate +import org.springframework.data.annotation.LastModifiedDate +import org.springframework.data.jpa.domain.support.AuditingEntityListener +import java.time.OffsetDateTime + +@Entity +@Table(name = "vouchers") +@EntityListeners(AuditingEntityListener::class) +class VoucherJpaEntity( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + @Enumerated(EnumType.STRING) + val type: VoucherType, + @Enumerated(EnumType.STRING) + val consumptionType: ConsumptionType, + @Enumerated(EnumType.STRING) + var status: VoucherStatus, + @CreatedDate + var createdAt: OffsetDateTime? = null, + @LastModifiedDate + var updatedAt: OffsetDateTime? = null +) { + fun toDomain(): Voucher { + val id = requireNotNull(id) { "Voucher.id must not be null" } + val createdAt = requireNotNull(createdAt) { "Voucher.createdAt must not be null" } + val updatedAt = requireNotNull(updatedAt) { "Voucher.updatedAt must not be null" } + + return Voucher( + id = id, + type = type, + consumptionType = consumptionType, + status = status, + createdAt = createdAt, + updatedAt = updatedAt + ) + } +} diff --git a/payment/voucher/adapter/src/main/kotlin/app/payment/voucher/adapter/outbound/VoucherJpaRepository.kt b/payment/voucher/adapter/src/main/kotlin/app/payment/voucher/adapter/outbound/VoucherJpaRepository.kt new file mode 100644 index 0000000..8d96fdc --- /dev/null +++ b/payment/voucher/adapter/src/main/kotlin/app/payment/voucher/adapter/outbound/VoucherJpaRepository.kt @@ -0,0 +1,8 @@ +package app.payment.voucher.adapter.outbound + +import app.payment.voucher.domain.VoucherStatus +import org.springframework.data.jpa.repository.JpaRepository + +interface VoucherJpaRepository : JpaRepository { + fun findByStatus(status: VoucherStatus): List +} diff --git a/payment/voucher/adapter/src/main/kotlin/app/payment/voucher/adapter/outbound/VoucherReaderImpl.kt b/payment/voucher/adapter/src/main/kotlin/app/payment/voucher/adapter/outbound/VoucherReaderImpl.kt new file mode 100644 index 0000000..9048db5 --- /dev/null +++ b/payment/voucher/adapter/src/main/kotlin/app/payment/voucher/adapter/outbound/VoucherReaderImpl.kt @@ -0,0 +1,16 @@ +package app.payment.voucher.adapter.outbound + +import app.payment.voucher.application.port.outbound.VoucherReader +import app.payment.voucher.domain.Voucher +import app.payment.voucher.domain.VoucherStatus +import org.springframework.stereotype.Repository + +@Repository +class VoucherReaderImpl( + private val voucherRepository: VoucherJpaRepository +): VoucherReader { + override fun readByStatus(status: VoucherStatus): List { + return voucherRepository.findByStatus(status) + .map { it.toDomain() } + } +} diff --git a/payment/voucher/adapter/src/test/kotlin/app/payment/voucher/adapter/TestConfiguration.kt b/payment/voucher/adapter/src/test/kotlin/app/payment/voucher/adapter/TestConfiguration.kt new file mode 100644 index 0000000..5c682a5 --- /dev/null +++ b/payment/voucher/adapter/src/test/kotlin/app/payment/voucher/adapter/TestConfiguration.kt @@ -0,0 +1,10 @@ +package app.payment.voucher.adapter + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.autoconfigure.domain.EntityScan +import org.springframework.data.jpa.repository.config.EnableJpaRepositories + +@SpringBootApplication +@EntityScan("app.payment.voucher.adapter.outbound") +@EnableJpaRepositories("app.payment.voucher.adapter.outbound") +class TestConfiguration \ No newline at end of file diff --git a/payment/voucher/adapter/src/test/kotlin/app/payment/voucher/adapter/inbound/VoucherControllerTest.kt b/payment/voucher/adapter/src/test/kotlin/app/payment/voucher/adapter/inbound/VoucherControllerTest.kt new file mode 100644 index 0000000..1b78cff --- /dev/null +++ b/payment/voucher/adapter/src/test/kotlin/app/payment/voucher/adapter/inbound/VoucherControllerTest.kt @@ -0,0 +1,67 @@ +package app.payment.voucher.adapter.inbound + +import app.payment.voucher.application.port.inbound.CurrentPublishedVouchers +import app.payment.voucher.application.port.inbound.VoucherUseCase +import app.payment.voucher.domain.ConsumptionType +import app.payment.voucher.domain.VoucherType +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.BeforeEach +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import java.time.OffsetDateTime + +class VoucherControllerTest { + + private lateinit var mockMvc: MockMvc + private lateinit var voucherUseCase: VoucherUseCase + + @BeforeEach + fun setUp() { + voucherUseCase = mockk() + mockMvc = MockMvcBuilders.standaloneSetup(VoucherController(voucherUseCase)).build() + } + + @Test + @DisplayName("GET /api/v1/vouchers 요청 시 발행된 바우처 목록을 반환한다") + fun `should return published vouchers`() { + // given + val vouchers = listOf( + CurrentPublishedVouchers( + id = 1L, + type = VoucherType.AI_POSTER_GENERATE, + consumptionType = ConsumptionType.SINGLE_USE, + title = "AI 포스터 생성", + description = "AI를 사용한 포스터 생성 바우처", + activeUntil = OffsetDateTime.now().plusDays(30) + ) + ) + + every { voucherUseCase.getPublishedVouchers() } returns vouchers + + // when & then + mockMvc.perform(get("/api/v1/vouchers")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.vouchers[0].id").value(1)) + .andExpect(jsonPath("$.vouchers[0].title").value("AI 포스터 생성")) + .andExpect(jsonPath("$.vouchers[0].consumptionType").value("SINGLE_USE")) + .andExpect(jsonPath("$.vouchers[0].description").value("AI를 사용한 포스터 생성 바우처")) + } + + @Test + @DisplayName("발행된 바우처가 없으면 빈 목록을 반환한다") + fun `should return empty list when no published vouchers`() { + // given + every { voucherUseCase.getPublishedVouchers() } returns emptyList() + + // when & then + mockMvc.perform(get("/api/v1/vouchers")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.vouchers").isEmpty()) + } +} diff --git a/payment/voucher/adapter/src/test/kotlin/app/payment/voucher/adapter/outbound/VoucherReaderImplTest.kt b/payment/voucher/adapter/src/test/kotlin/app/payment/voucher/adapter/outbound/VoucherReaderImplTest.kt new file mode 100644 index 0000000..88b284e --- /dev/null +++ b/payment/voucher/adapter/src/test/kotlin/app/payment/voucher/adapter/outbound/VoucherReaderImplTest.kt @@ -0,0 +1,61 @@ +package app.payment.voucher.adapter.outbound + +import app.payment.voucher.domain.ConsumptionType +import app.payment.voucher.domain.VoucherStatus +import app.payment.voucher.domain.VoucherType +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.BeforeEach +import java.time.OffsetDateTime + +class VoucherReaderImplTest { + + private lateinit var voucherRepository: VoucherJpaRepository + private lateinit var voucherReader: VoucherReaderImpl + + @BeforeEach + fun setUp() { + voucherRepository = mockk() + voucherReader = VoucherReaderImpl(voucherRepository) + } + + @Test + @DisplayName("특정 상태의 바우처를 조회할 수 있다") + fun `should find vouchers by status`() { + // given + val publishedEntity = VoucherJpaEntity( + id = 1L, + type = VoucherType.AI_POSTER_GENERATE, + consumptionType = ConsumptionType.SINGLE_USE, + status = VoucherStatus.PUBLISHED, + createdAt = OffsetDateTime.now(), + updatedAt = OffsetDateTime.now() + ) + + every { voucherRepository.findByStatus(VoucherStatus.PUBLISHED) } returns listOf(publishedEntity) + + // when + val result = voucherReader.readByStatus(VoucherStatus.PUBLISHED) + + // then + assertEquals(1, result.size) + assertEquals(VoucherStatus.PUBLISHED, result[0].status) + assertEquals(1L, result[0].id) + } + + @Test + @DisplayName("해당 상태의 바우처가 없으면 빈 목록을 반환한다") + fun `should return empty list when no vouchers with status`() { + // given + every { voucherRepository.findByStatus(VoucherStatus.PAUSED) } returns emptyList() + + // when + val result = voucherReader.readByStatus(VoucherStatus.PAUSED) + + // then + assertTrue(result.isEmpty()) + } +} \ No newline at end of file diff --git a/payment/voucher/adapter/src/test/resources/application-test.yml b/payment/voucher/adapter/src/test/resources/application-test.yml new file mode 100644 index 0000000..31c6149 --- /dev/null +++ b/payment/voucher/adapter/src/test/resources/application-test.yml @@ -0,0 +1,21 @@ +spring: + main: + allow-bean-definition-overriding: true + datasource: + url: jdbc:h2:mem:testdb;MODE=MySQL + driver-class-name: org.h2.Driver + username: sa + password: + + jpa: + hibernate: + ddl-auto: create-drop + properties: + hibernate: + format_sql: true + show_sql: false + dialect: org.hibernate.dialect.H2Dialect + +logging: + level: + org.hibernate.SQL: DEBUG \ No newline at end of file diff --git a/payment/voucher/application/build.gradle.kts b/payment/voucher/application/build.gradle.kts new file mode 100644 index 0000000..ea60a28 --- /dev/null +++ b/payment/voucher/application/build.gradle.kts @@ -0,0 +1,25 @@ +plugins { + kotlin("jvm") + kotlin("plugin.spring") + id("io.spring.dependency-management") +} + + +dependencies { + implementation(project(":voucher-domain")) + implementation("org.springframework:spring-context") + implementation("org.springframework:spring-tx") + implementation("org.springframework:spring-aop") + + + testImplementation("org.springframework.boot:spring-boot-starter-test") { + exclude(group = "org.mockito") + } + testImplementation("io.mockk:mockk") + testImplementation("com.ninja-squad:springmockk:4.0.2") + +} + +tasks.withType { + useJUnitPlatform() +} \ No newline at end of file diff --git a/payment/voucher/application/src/main/kotlin/app/payment/voucher/application/port/inbound/CurrentPublishedVouchers.kt b/payment/voucher/application/src/main/kotlin/app/payment/voucher/application/port/inbound/CurrentPublishedVouchers.kt new file mode 100644 index 0000000..3ec5b46 --- /dev/null +++ b/payment/voucher/application/src/main/kotlin/app/payment/voucher/application/port/inbound/CurrentPublishedVouchers.kt @@ -0,0 +1,14 @@ +package app.payment.voucher.application.port.inbound + +import app.payment.voucher.domain.ConsumptionType +import app.payment.voucher.domain.VoucherType +import java.time.OffsetDateTime + +data class CurrentPublishedVouchers( + val id: Long, + val type: VoucherType, + val consumptionType: ConsumptionType, + val title: String, + val description: String, + val activeUntil: OffsetDateTime, +) diff --git a/payment/voucher/application/src/main/kotlin/app/payment/voucher/application/port/inbound/VoucherUseCase.kt b/payment/voucher/application/src/main/kotlin/app/payment/voucher/application/port/inbound/VoucherUseCase.kt new file mode 100644 index 0000000..cbcc983 --- /dev/null +++ b/payment/voucher/application/src/main/kotlin/app/payment/voucher/application/port/inbound/VoucherUseCase.kt @@ -0,0 +1,5 @@ +package app.payment.voucher.application.port.inbound + +interface VoucherUseCase { + fun getPublishedVouchers(): List +} diff --git a/payment/voucher/application/src/main/kotlin/app/payment/voucher/application/port/outbound/VoucherContentReader.kt b/payment/voucher/application/src/main/kotlin/app/payment/voucher/application/port/outbound/VoucherContentReader.kt new file mode 100644 index 0000000..7309042 --- /dev/null +++ b/payment/voucher/application/src/main/kotlin/app/payment/voucher/application/port/outbound/VoucherContentReader.kt @@ -0,0 +1,11 @@ +package app.payment.voucher.application.port.outbound + +import app.payment.voucher.domain.VoucherContent +import java.time.OffsetDateTime + +interface VoucherContentReader { + fun readCurrentContents( + publishedVoucherIds: List, + now: OffsetDateTime, + ): List +} diff --git a/payment/voucher/application/src/main/kotlin/app/payment/voucher/application/port/outbound/VoucherReader.kt b/payment/voucher/application/src/main/kotlin/app/payment/voucher/application/port/outbound/VoucherReader.kt new file mode 100644 index 0000000..c2bb7ee --- /dev/null +++ b/payment/voucher/application/src/main/kotlin/app/payment/voucher/application/port/outbound/VoucherReader.kt @@ -0,0 +1,8 @@ +package app.payment.voucher.application.port.outbound + +import app.payment.voucher.domain.Voucher +import app.payment.voucher.domain.VoucherStatus + +interface VoucherReader { + fun readByStatus(status: VoucherStatus): List +} diff --git a/payment/voucher/application/src/main/kotlin/app/payment/voucher/application/service/VoucherService.kt b/payment/voucher/application/src/main/kotlin/app/payment/voucher/application/service/VoucherService.kt new file mode 100644 index 0000000..efa2bef --- /dev/null +++ b/payment/voucher/application/src/main/kotlin/app/payment/voucher/application/service/VoucherService.kt @@ -0,0 +1,40 @@ +package app.payment.voucher.application.service + +import app.payment.voucher.application.port.inbound.CurrentPublishedVouchers +import app.payment.voucher.application.port.inbound.VoucherUseCase +import app.payment.voucher.application.port.outbound.VoucherContentReader +import app.payment.voucher.application.port.outbound.VoucherReader +import app.payment.voucher.domain.VoucherStatus +import org.springframework.stereotype.Service +import java.time.OffsetDateTime + +@Service +class VoucherService( + private val voucherReader: VoucherReader, + private val voucherContentReader: VoucherContentReader, +) : VoucherUseCase { + override fun getPublishedVouchers(): List { + val publishedVouchers = voucherReader.readByStatus(VoucherStatus.PUBLISHED) + if (publishedVouchers.isEmpty()) { + return emptyList() + } + + val publishedVoucherIds = publishedVouchers.map { it.id } + val now = OffsetDateTime.now() + val currentVoucherContents = voucherContentReader.readCurrentContents(publishedVoucherIds, now) + val contentsByVoucherId = currentVoucherContents.associateBy { it.voucherId } + + return publishedVouchers.mapNotNull { voucher -> + val voucherContent = contentsByVoucherId[voucher.id] ?: return@mapNotNull null + + CurrentPublishedVouchers( + id = voucher.id, + type = voucher.type, + consumptionType = voucher.consumptionType, + title = voucherContent.title, + description = voucherContent.description, + activeUntil = voucherContent.activeUntil, + ) + } + } +} diff --git a/payment/voucher/application/src/test/kotlin/app/payment/voucher/application/service/VoucherServiceTest.kt b/payment/voucher/application/src/test/kotlin/app/payment/voucher/application/service/VoucherServiceTest.kt new file mode 100644 index 0000000..253e283 --- /dev/null +++ b/payment/voucher/application/src/test/kotlin/app/payment/voucher/application/service/VoucherServiceTest.kt @@ -0,0 +1,93 @@ +package app.payment.voucher.application.service + +import app.payment.voucher.application.port.outbound.VoucherContentReader +import app.payment.voucher.application.port.outbound.VoucherReader +import app.payment.voucher.domain.* +import io.mockk.* +import io.mockk.impl.annotations.InjectMockKs +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.extension.ExtendWith +import java.time.OffsetDateTime + +@ExtendWith(MockKExtension::class) +class VoucherServiceTest { + + @MockK + private lateinit var voucherReader: VoucherReader + + @MockK + private lateinit var voucherContentReader: VoucherContentReader + + @InjectMockKs + private lateinit var voucherService: VoucherService + + @BeforeEach + fun setUp() { + clearMocks(voucherReader, voucherContentReader) + } + + @Test + @DisplayName("발행된 바우처 목록을 조회할 수 있다") + fun `should get published vouchers`() { + // given + val vouchers = listOf(createVoucher(1L), createVoucher(2L)) + val contents = listOf(createVoucherContent(1L), createVoucherContent(2L)) + + every { voucherReader.readByStatus(VoucherStatus.PUBLISHED) } returns vouchers + every { + voucherContentReader.readCurrentContents(listOf(1L, 2L), any()) + } returns contents + + // when + val result = voucherService.getPublishedVouchers() + + // then + assertEquals(2, result.size) + assertEquals(1L, result[0].id) + assertEquals("Test Title", result[0].title) + + verify(exactly = 1) { voucherReader.readByStatus(VoucherStatus.PUBLISHED) } + verify(exactly = 1) { voucherContentReader.readCurrentContents(any(), any()) } + } + + @Test + @DisplayName("발행된 바우처가 없으면 빈 목록을 반환한다") + fun `should return empty list when no published vouchers`() { + // given + every { voucherReader.readByStatus(VoucherStatus.PUBLISHED) } returns emptyList() + + // when + val result = voucherService.getPublishedVouchers() + + // then + assertTrue(result.isEmpty()) + + verify(exactly = 1) { voucherReader.readByStatus(VoucherStatus.PUBLISHED) } + verify(exactly = 0) { voucherContentReader.readCurrentContents(any(), any()) } + } + + private fun createVoucher(id: Long) = Voucher( + id = id, + type = VoucherType.AI_POSTER_GENERATE, + consumptionType = ConsumptionType.SINGLE_USE, + status = VoucherStatus.PUBLISHED, + createdAt = OffsetDateTime.now(), + updatedAt = OffsetDateTime.now() + ) + + private fun createVoucherContent(voucherId: Long) = VoucherContent( + id = voucherId * 10, + voucherId = voucherId, + version = 1, + title = "Test Title", + description = "Test Description", + activeFrom = OffsetDateTime.now().minusDays(1), + activeUntil = OffsetDateTime.now().plusDays(1), + createdAt = OffsetDateTime.now() + ) +} diff --git a/payment/voucher/domain/build.gradle.kts b/payment/voucher/domain/build.gradle.kts new file mode 100644 index 0000000..d36107d --- /dev/null +++ b/payment/voucher/domain/build.gradle.kts @@ -0,0 +1,21 @@ +plugins { + kotlin("jvm") + `java-library` + id("io.spring.dependency-management") +} + +kotlin { + jvmToolchain(21) +} + +dependencies { + // 순수 도메인 + + // 테스트 - Spring Boot BOM 사용 (루트에서 자동 추가됨) + testImplementation("org.junit.jupiter:junit-jupiter") + testImplementation("org.assertj:assertj-core") +} + +tasks.withType { + useJUnitPlatform() +} diff --git a/payment/src/main/kotlin/app/payment/domain/voucher/ConsumptionType.kt b/payment/voucher/domain/src/main/kotlin/app/payment/voucher/domain/ConsumptionType.kt similarity index 65% rename from payment/src/main/kotlin/app/payment/domain/voucher/ConsumptionType.kt rename to payment/voucher/domain/src/main/kotlin/app/payment/voucher/domain/ConsumptionType.kt index 16031fb..02bc670 100644 --- a/payment/src/main/kotlin/app/payment/domain/voucher/ConsumptionType.kt +++ b/payment/voucher/domain/src/main/kotlin/app/payment/voucher/domain/ConsumptionType.kt @@ -1,4 +1,4 @@ -package app.payment.domain.voucher +package app.payment.voucher.domain enum class ConsumptionType { SINGLE_USE, diff --git a/payment/voucher/domain/src/main/kotlin/app/payment/voucher/domain/Voucher.kt b/payment/voucher/domain/src/main/kotlin/app/payment/voucher/domain/Voucher.kt new file mode 100644 index 0000000..64a9629 --- /dev/null +++ b/payment/voucher/domain/src/main/kotlin/app/payment/voucher/domain/Voucher.kt @@ -0,0 +1,12 @@ +package app.payment.voucher.domain + +import java.time.OffsetDateTime + +class Voucher( + public val id: Long, + public val type: VoucherType, + public val consumptionType: ConsumptionType, + public var status: VoucherStatus, + public val createdAt: OffsetDateTime, + public var updatedAt: OffsetDateTime, +) diff --git a/payment/voucher/domain/src/main/kotlin/app/payment/voucher/domain/VoucherContent.kt b/payment/voucher/domain/src/main/kotlin/app/payment/voucher/domain/VoucherContent.kt new file mode 100644 index 0000000..2761b17 --- /dev/null +++ b/payment/voucher/domain/src/main/kotlin/app/payment/voucher/domain/VoucherContent.kt @@ -0,0 +1,14 @@ +package app.payment.voucher.domain + +import java.time.OffsetDateTime + +class VoucherContent( + public val id: Long, + public val voucherId: Long, + public val version: Int, + public val title: String, + public val description: String, + public val activeFrom: OffsetDateTime, + public val activeUntil: OffsetDateTime, + public val createdAt: OffsetDateTime, +) diff --git a/payment/src/main/kotlin/app/payment/domain/voucher/VoucherStatus.kt b/payment/voucher/domain/src/main/kotlin/app/payment/voucher/domain/VoucherStatus.kt similarity index 71% rename from payment/src/main/kotlin/app/payment/domain/voucher/VoucherStatus.kt rename to payment/voucher/domain/src/main/kotlin/app/payment/voucher/domain/VoucherStatus.kt index 47a4420..94dcf24 100644 --- a/payment/src/main/kotlin/app/payment/domain/voucher/VoucherStatus.kt +++ b/payment/voucher/domain/src/main/kotlin/app/payment/voucher/domain/VoucherStatus.kt @@ -1,4 +1,4 @@ -package app.payment.domain.voucher +package app.payment.voucher.domain enum class VoucherStatus { DRAFT, diff --git a/payment/src/main/kotlin/app/payment/domain/voucher/VoucherType.kt b/payment/voucher/domain/src/main/kotlin/app/payment/voucher/domain/VoucherType.kt similarity index 74% rename from payment/src/main/kotlin/app/payment/domain/voucher/VoucherType.kt rename to payment/voucher/domain/src/main/kotlin/app/payment/voucher/domain/VoucherType.kt index cd03985..a79f71a 100644 --- a/payment/src/main/kotlin/app/payment/domain/voucher/VoucherType.kt +++ b/payment/voucher/domain/src/main/kotlin/app/payment/voucher/domain/VoucherType.kt @@ -1,4 +1,4 @@ -package app.payment.domain.voucher +package app.payment.voucher.domain enum class VoucherType { AI_POSTER_GENERATE, diff --git a/payment/voucher/domain/src/test/kotlin/app/payment/voucher/domain/VoucherTest.kt b/payment/voucher/domain/src/test/kotlin/app/payment/voucher/domain/VoucherTest.kt new file mode 100644 index 0000000..4cd3383 --- /dev/null +++ b/payment/voucher/domain/src/test/kotlin/app/payment/voucher/domain/VoucherTest.kt @@ -0,0 +1,45 @@ +package app.payment.voucher.domain + +import org.assertj.core.api.Assertions.* +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.DisplayName +import java.time.OffsetDateTime + +class VoucherTest { + + @Test + @DisplayName("바우처 상태를 변경할 수 있다") + fun `should change voucher status`() { + // given + val voucher = createVoucher() + + // when + voucher.status = VoucherStatus.PAUSED + + // then + assertThat(voucher.status).isEqualTo(VoucherStatus.PAUSED) + } + + @Test + @DisplayName("바우처가 활성 상태인지 확인할 수 있다") + fun `should check if voucher is active`() { + // given + val activeVoucher = createVoucher(status = VoucherStatus.PUBLISHED) + val pausedVoucher = createVoucher(status = VoucherStatus.PAUSED) + + // then + assertThat(activeVoucher.status).isEqualTo(VoucherStatus.PUBLISHED) + assertThat(pausedVoucher.status).isNotEqualTo(VoucherStatus.PUBLISHED) + } + + private fun createVoucher( + status: VoucherStatus = VoucherStatus.DRAFT + ) = Voucher( + id = 1L, + type = VoucherType.AI_POSTER_GENERATE, + consumptionType = ConsumptionType.SINGLE_USE, + status = status, + createdAt = OffsetDateTime.now(), + updatedAt = OffsetDateTime.now() + ) +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 9128878..b565d59 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -21,6 +21,18 @@ project(":member-adapter").projectDir = file("platform/member/adapter") project(":platform-api").projectDir = file("platform/services/api") +// ---------- payment ---------- +include( + "voucher-domain", + "voucher-application", + "voucher-adapter", + "payment-api") + +project(":voucher-domain").projectDir = file("payment/voucher/domain") +project(":voucher-application").projectDir = file("payment/voucher/application") +project(":voucher-adapter").projectDir = file("payment/voucher/adapter") +project(":payment-api").projectDir = file("payment/services/api") + // ---------- libs ---------- include(