diff --git a/Makefile b/Makefile index 4b05d93..c680dd6 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ SPANNER_DATABASE_ID=gsync .PHONY: build build: - ./gradlew build -x test + ./gradlew quarkusBuild .PHONY: build_native build_native: diff --git a/build.gradle.kts b/build.gradle.kts index aacbc94..d14bb8c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,5 @@ plugins { kotlin("jvm") version libs.versions.kotlin - kotlin("plugin.allopen") version libs.versions.kotlin alias(libs.plugins.versions) alias(libs.plugins.version.catalog.update) @@ -17,15 +16,8 @@ plugins { idea } -buildscript { - dependencies { - classpath("com.github.ben-manes:gradle-versions-plugin:0.47.0") - } -} - repositories { mavenCentral() - mavenLocal() gradlePluginPortal() } @@ -55,27 +47,28 @@ dependencies { implementation(libs.jackson.module.kotlin) // Test Framework & utils + testImplementation(project(":testkit")) testImplementation(libs.quarkus.junit5) testImplementation(libs.spock.core) - testImplementation(libs.spock.junit4) testImplementation(libs.groovy) testImplementation(libs.groovy.sql) testImplementation(libs.easy.random) } java { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } } kotlin { - jvmToolchain(17) -} - -allOpen { - annotation("jakarta.ws.rs.Path") - annotation("jakarta.enterprise.context.ApplicationScoped") - annotation("io.quarkus.test.junit.QuarkusTest") + sourceSets { + all { + languageSettings { + languageVersion = "2.0" + } + } + } } spotless { @@ -93,6 +86,7 @@ sonar { property("sonar.projectKey", "averak_gsync") property("sonar.organization", "averak") property("sonar.host.url", "https://sonarcloud.io") + property("sonar.exclusions", "testkit/**") } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1ee5709..5f4a395 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,11 +1,12 @@ [versions] easy-random = "6.2.1" groovy = "4.0.7" -kotlin = "1.9.10" +kotlin = "1.9.20" quarkus = "3.5.0" spock = "2.4-M1-groovy-4.0" [libraries] +commons-lang3 = "org.apache.commons:commons-lang3:3.13.0" easy-random = { module = "io.github.dvgaba:easy-random-core", version.ref = "easy-random" } groovy = { module = "org.apache.groovy:groovy", version.ref = "groovy" } groovy-sql = { module = "org.apache.groovy:groovy-sql", version.ref = "groovy" } @@ -23,7 +24,6 @@ quarkus-logging-json = { module = "io.quarkus:quarkus-logging-json", version.ref quarkus-resteasy-reactive = { module = "io.quarkus:quarkus-resteasy-reactive", version.ref = "quarkus" } quarkus-resteasy-reactive-jackson = { module = "io.quarkus:quarkus-resteasy-reactive-jackson", version.ref = "quarkus" } spock-core = { module = "org.spockframework:spock-core", version.ref = "spock" } -spock-junit4 = { module = "org.spockframework:spock-junit4", version.ref = "spock" } [plugins] gradle-git-properties = "com.gorylenko.gradle-git-properties:2.4.1" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index db9a6b8..e411586 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/settings.gradle b/settings.gradle index 029126b..33b12cc 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1,2 @@ rootProject.name = "gsync" +include(":testkit") diff --git a/src/main/kotlin/net/averak/gsync/adapter/handler/exception/ErrorResponse.kt b/src/main/kotlin/net/averak/gsync/adapter/handler/exception/ErrorResponse.kt index 9ab0595..b92c98b 100644 --- a/src/main/kotlin/net/averak/gsync/adapter/handler/exception/ErrorResponse.kt +++ b/src/main/kotlin/net/averak/gsync/adapter/handler/exception/ErrorResponse.kt @@ -4,7 +4,7 @@ import net.averak.gsync.core.exception.ErrorCode data class ErrorResponse( val code: String, - val description: String, + val summary: String, ) { - constructor(errorCode: ErrorCode) : this(errorCode.name, errorCode.description) + constructor(errorCode: ErrorCode) : this(errorCode.name, errorCode.summary) } diff --git a/src/main/kotlin/net/averak/gsync/adapter/handler/exception/NotFoundExceptionMapper.kt b/src/main/kotlin/net/averak/gsync/adapter/handler/exception/NotFoundExceptionMapper.kt index 50f6265..6576c32 100644 --- a/src/main/kotlin/net/averak/gsync/adapter/handler/exception/NotFoundExceptionMapper.kt +++ b/src/main/kotlin/net/averak/gsync/adapter/handler/exception/NotFoundExceptionMapper.kt @@ -1,15 +1,19 @@ package net.averak.gsync.adapter.handler.exception import jakarta.ws.rs.NotFoundException +import jakarta.ws.rs.core.MediaType import jakarta.ws.rs.core.Response import jakarta.ws.rs.ext.ExceptionMapper import jakarta.ws.rs.ext.Provider import net.averak.gsync.core.exception.ErrorCode -import org.apache.http.HttpStatus @Provider class NotFoundExceptionMapper : ExceptionMapper { override fun toResponse(exception: NotFoundException?): Response { - return Response.status(HttpStatus.SC_NOT_FOUND).entity(ErrorResponse(ErrorCode.NOT_FOUND_API)).build() + return Response + .status(Response.Status.NOT_FOUND) + .type(MediaType.APPLICATION_JSON_TYPE) + .entity(ErrorResponse(ErrorCode.NOT_FOUND_API)) + .build() } } diff --git a/src/main/kotlin/net/averak/gsync/core/exception/ErrorCode.kt b/src/main/kotlin/net/averak/gsync/core/exception/ErrorCode.kt index c79b077..6501591 100644 --- a/src/main/kotlin/net/averak/gsync/core/exception/ErrorCode.kt +++ b/src/main/kotlin/net/averak/gsync/core/exception/ErrorCode.kt @@ -1,6 +1,6 @@ package net.averak.gsync.core.exception -enum class ErrorCode(val description: String) { +enum class ErrorCode(val summary: String) { // 400 Bad Request VALIDATION_ERROR("Request validation exception was thrown."), INVALID_REQUEST_PARAMETERS("Request parameters is invalid."), diff --git a/src/main/kotlin/net/averak/gsync/core/exception/GsyncException.kt b/src/main/kotlin/net/averak/gsync/core/exception/GsyncException.kt new file mode 100644 index 0000000..a269e3d --- /dev/null +++ b/src/main/kotlin/net/averak/gsync/core/exception/GsyncException.kt @@ -0,0 +1,5 @@ +package net.averak.gsync.core.exception + +class GsyncException(val errorCode: ErrorCode, val causedBy: Throwable?) : RuntimeException(errorCode.summary, causedBy) { + constructor(errorCode: ErrorCode) : this(errorCode, null) +} diff --git a/src/main/kotlin/net/averak/gsync/domain/model/Echo.kt b/src/main/kotlin/net/averak/gsync/domain/model/Echo.kt new file mode 100644 index 0000000..2740b67 --- /dev/null +++ b/src/main/kotlin/net/averak/gsync/domain/model/Echo.kt @@ -0,0 +1,16 @@ +package net.averak.gsync.domain.model + +import net.averak.gsync.domain.primitive.common.ID +import java.time.LocalDateTime + +data class Echo( + val id: ID, + val message: String, + val timestamp: LocalDateTime, +) { + constructor(message: String) : this( + ID(), + message, + LocalDateTime.now(), + ) +} diff --git a/src/main/kotlin/net/averak/gsync/domain/primitive/common/ID.kt b/src/main/kotlin/net/averak/gsync/domain/primitive/common/ID.kt new file mode 100644 index 0000000..ef22892 --- /dev/null +++ b/src/main/kotlin/net/averak/gsync/domain/primitive/common/ID.kt @@ -0,0 +1,17 @@ +package net.averak.gsync.domain.primitive.common + +import net.averak.gsync.core.exception.ErrorCode +import net.averak.gsync.core.exception.GsyncException +import java.util.UUID + +data class ID(val value: String) { + init { + try { + UUID.fromString(value) + } catch (_: IllegalArgumentException) { + throw GsyncException(ErrorCode.ID_FORMAT_IS_INVALID) + } + } + + constructor() : this(UUID.randomUUID().toString()) +} diff --git a/src/main/kotlin/net/averak/gsync/usecase/EchoUsecase.kt b/src/main/kotlin/net/averak/gsync/usecase/EchoUsecase.kt new file mode 100644 index 0000000..390de61 --- /dev/null +++ b/src/main/kotlin/net/averak/gsync/usecase/EchoUsecase.kt @@ -0,0 +1,11 @@ +package net.averak.gsync.usecase + +import jakarta.inject.Singleton +import net.averak.gsync.domain.model.Echo + +@Singleton +class EchoUsecase { + fun echo(message: String): Echo { + return Echo(message) + } +} diff --git a/src/test/groovy/net/averak/gsync/AbstractSpec.groovy b/src/test/groovy/net/averak/gsync/AbstractSpec.groovy index 93fe2ae..fbc18b6 100644 --- a/src/test/groovy/net/averak/gsync/AbstractSpec.groovy +++ b/src/test/groovy/net/averak/gsync/AbstractSpec.groovy @@ -1,8 +1,31 @@ package net.averak.gsync +import io.quarkus.arc.All import io.quarkus.test.junit.QuarkusTest +import jakarta.annotation.PostConstruct +import jakarta.inject.Inject +import net.averak.gsync.core.exception.GsyncException +import net.averak.gsync.testkit.Faker +import net.averak.gsync.testkit.IRandomizer import spock.lang.Specification @QuarkusTest abstract class AbstractSpec extends Specification { + + @Inject + @All + List randomizers + + @PostConstruct + void init() { + Faker.init(randomizers) + } + + /** + * 例外を検証 + */ + static void verify(final GsyncException actual, final GsyncException expected) { + assert actual.errorCode == expected.errorCode + } + } diff --git a/src/test/groovy/net/averak/gsync/adapter/handler/controller/AbstractController_IT.groovy b/src/test/groovy/net/averak/gsync/adapter/handler/controller/AbstractController_IT.groovy index e97bce7..ca0759d 100644 --- a/src/test/groovy/net/averak/gsync/adapter/handler/controller/AbstractController_IT.groovy +++ b/src/test/groovy/net/averak/gsync/adapter/handler/controller/AbstractController_IT.groovy @@ -2,5 +2,5 @@ package net.averak.gsync.adapter.handler.controller import net.averak.gsync.AbstractSpec -class AbstractController_IT extends AbstractSpec { +abstract class AbstractController_IT extends AbstractSpec { } diff --git a/src/test/groovy/net/averak/gsync/domain/primitive/common/ID_UT.groovy b/src/test/groovy/net/averak/gsync/domain/primitive/common/ID_UT.groovy new file mode 100644 index 0000000..04e1b45 --- /dev/null +++ b/src/test/groovy/net/averak/gsync/domain/primitive/common/ID_UT.groovy @@ -0,0 +1,39 @@ +package net.averak.gsync.domain.primitive.common + +import net.averak.gsync.AbstractSpec +import net.averak.gsync.core.exception.ErrorCode +import net.averak.gsync.core.exception.GsyncException +import net.averak.gsync.testkit.Faker + +class ID_UT extends AbstractSpec { + + def "constructor: 正常に作成できる"() { + when: + new ID(value) + + then: + noExceptionThrown() + + where: + value << [ + Faker.uuidv4(), + Faker.uuidv5("1"), + ] + } + + def "ID: 制約違反の場合は例外を返す"() { + when: + new ID(value) + + then: + final exception = thrown(GsyncException) + verify(exception, new GsyncException(ErrorCode.ID_FORMAT_IS_INVALID)) + + where: + value << [ + Faker.alphanumeric(36), + Faker.uuidv4() + "A", + ] + } + +} \ No newline at end of file diff --git a/src/test/groovy/net/averak/gsync/randomizer/domain/primitive/common/IDRandomizer.groovy b/src/test/groovy/net/averak/gsync/randomizer/domain/primitive/common/IDRandomizer.groovy new file mode 100644 index 0000000..3bf8e90 --- /dev/null +++ b/src/test/groovy/net/averak/gsync/randomizer/domain/primitive/common/IDRandomizer.groovy @@ -0,0 +1,16 @@ +package net.averak.gsync.randomizer.domain.primitive.common + +import net.averak.gsync.domain.primitive.common.ID +import net.averak.gsync.testkit.IRandomizer + +@Singleton +class IDRandomizer implements IRandomizer { + + final Class typeToGenerate = ID.class + + @Override + Object getRandomValue() { + return new ID() + } + +} diff --git a/src/test/groovy/net/averak/gsync/usecase/AbstractUsecase_UT.groovy b/src/test/groovy/net/averak/gsync/usecase/AbstractUsecase_UT.groovy new file mode 100644 index 0000000..fa65aa0 --- /dev/null +++ b/src/test/groovy/net/averak/gsync/usecase/AbstractUsecase_UT.groovy @@ -0,0 +1,6 @@ +package net.averak.gsync.usecase + +import net.averak.gsync.AbstractSpec + +abstract class AbstractUsecase_UT extends AbstractSpec { +} diff --git a/src/test/groovy/net/averak/gsync/usecase/EchoUsecase_UT.groovy b/src/test/groovy/net/averak/gsync/usecase/EchoUsecase_UT.groovy new file mode 100644 index 0000000..a974b1d --- /dev/null +++ b/src/test/groovy/net/averak/gsync/usecase/EchoUsecase_UT.groovy @@ -0,0 +1,21 @@ +package net.averak.gsync.usecase + +import jakarta.inject.Inject +import net.averak.gsync.testkit.Faker + +class EchoUsecase_Echo_UT extends AbstractUsecase_UT { + + @Inject + EchoUsecase sut + + def "echo: 正常系 Echoを作成できる"() { + given: + final message = Faker.alphanumeric() + + when: + final result = sut.echo(message) + + then: + result.message == message + } +} diff --git a/testkit/build.gradle.kts b/testkit/build.gradle.kts new file mode 100644 index 0000000..19bc9e2 --- /dev/null +++ b/testkit/build.gradle.kts @@ -0,0 +1,24 @@ +plugins { + kotlin("jvm") version libs.versions.kotlin +} + +repositories { + mavenCentral() +} + +dependencies { + implementation(libs.groovy.sql) + implementation(libs.easy.random) + implementation(libs.commons.lang3) + implementation(libs.guava) +} + +kotlin { + sourceSets { + all { + languageSettings { + languageVersion = "2.0" + } + } + } +} diff --git a/testkit/src/main/kotlin/net/averak/gsync/testkit/Faker.kt b/testkit/src/main/kotlin/net/averak/gsync/testkit/Faker.kt new file mode 100644 index 0000000..3ccafcd --- /dev/null +++ b/testkit/src/main/kotlin/net/averak/gsync/testkit/Faker.kt @@ -0,0 +1,185 @@ +package net.averak.gsync.testkit + +import org.apache.commons.lang3.RandomStringUtils +import org.jeasy.random.EasyRandom +import org.jeasy.random.EasyRandomParameters +import java.time.LocalDate +import java.util.* + +class Faker { + + companion object { + + private lateinit var easyRandom: EasyRandom + + /** + * 初期化する + * テストのエントリーポイントから必ず呼び出すこと + */ + @JvmStatic + fun init(randomizers: List>) { + val easyRandomParameters = EasyRandomParameters() + randomizers.forEach { + easyRandomParameters.randomize(it.getTypeToGenerate(), it) + } + easyRandom = EasyRandom(easyRandomParameters) + } + + /** + * 各フィールドにランダム値を格納したフェイクオブジェクトを生成する + * + * @param clazz target class + * @param fields ランダムに生成されたフィールドの値を強制上書きするマップ + */ + @JvmStatic + @JvmOverloads + fun fake(clazz: Class, fields: Map = mapOf()): T { + val obj = easyRandom.nextObject(clazz) + fields.forEach { (key, value) -> + val field = clazz.getDeclaredField(key) + field.isAccessible = true + field[obj] = value + field.isAccessible = false + } + return obj + } + + /** + * 各フィールドにランダム値を格納したフェイクオブジェクトリストを生成する + * + * @param clazz target class + * @param size number of generated objects + * @param fields ランダムに生成されたフィールドの値を強制上書きするマップ + */ + @JvmStatic + @JvmOverloads + fun fakes(clazz: Class, size: Int = 10, fields: Map = mapOf()): List { + val objs = easyRandom.objects(clazz, size).toList() + fields.forEach { (key, value) -> + val field = clazz.getDeclaredField(key) + field.isAccessible = true + objs.forEach { field[it] = value } + field.isAccessible = false + } + return objs + } + + /** + * メールアドレスを生成する + */ + @JvmStatic + fun email(): String { + return "${RandomStringUtils.randomAlphanumeric(10)}@${RandomStringUtils.randomAlphanumeric(5)}.com".lowercase(Locale.getDefault()) + } + + /** + * パスワードを生成する + */ + @JvmStatic + fun password(): String { + return "b9Fj5QYP" + RandomStringUtils.randomAlphanumeric(8) + } + + /** + * URLを生成する + */ + @JvmStatic + fun url(): String { + return "https://${RandomStringUtils.randomAlphanumeric(5)}.com/${RandomStringUtils.randomAlphanumeric(10)}" + } + + /** + * 数字のみの文字列を生成する + */ + @JvmStatic + @JvmOverloads + fun numeric(length: Int = 31): String { + return RandomStringUtils.randomNumeric(length) + } + + /** + * 英数字の文字列を生成する + */ + @JvmStatic + @JvmOverloads + fun alphanumeric(length: Int = 31): String { + return RandomStringUtils.randomAlphanumeric(length) + } + + /** + * 整数を生成する + */ + @JvmStatic + @JvmOverloads + fun integer(min: Int = 0, max: Int = Int.MAX_VALUE): Int { + val rand = Random() + return min + rand.nextInt(max - min) + } + + /** + * 自然数を生成する + */ + @JvmStatic + @JvmOverloads + fun naturalNumber(max: Int = Int.MAX_VALUE): Int { + return integer(1, max) + } + + /** + * BASE64エンコードされた文字列を生成 + */ + @JvmStatic + fun base64encoded(): String { + val encoder = Base64.getEncoder() + return encoder.encodeToString(RandomStringUtils.randomAlphanumeric(10).toByteArray()) + } + + /** + * リストからランダムに要素を抽出する + */ + @JvmStatic + fun dice(elements: List): T { + return elements[integer(0, elements.size - 1)] + } + + /** + * UUIDv4を生成する + */ + @JvmStatic + fun uuidv4(): String { + return UUID.randomUUID().toString() + } + + /** + * UUIDv5を生成する + */ + @JvmStatic + fun uuidv5(name: String): String { + return UUID.nameUUIDFromBytes(name.toByteArray()).toString() + } + + /** + * 本日の日付を生成する + */ + @JvmStatic + fun today(): LocalDate { + return LocalDate.now() + } + + /** + * 本日の日付を生成する + */ + @JvmStatic + fun tomorrow(): LocalDate { + return LocalDate.now().plusDays(1) + } + + /** + * 本日の日付を生成する + */ + @JvmStatic + fun yesterday(): LocalDate { + return LocalDate.now().minusDays(1) + } + } +} diff --git a/testkit/src/main/kotlin/net/averak/gsync/testkit/Fixture.kt b/testkit/src/main/kotlin/net/averak/gsync/testkit/Fixture.kt new file mode 100644 index 0000000..9671164 --- /dev/null +++ b/testkit/src/main/kotlin/net/averak/gsync/testkit/Fixture.kt @@ -0,0 +1,59 @@ +package net.averak.gsync.testkit + +import com.google.common.base.CaseFormat +import groovy.sql.Sql + +class Fixture { + + companion object { + + private lateinit var sql: Sql + + /** + * 初期化する + * テストのエントリーポイントから必ず呼び出すこと + */ + @JvmStatic + fun init(sql: Sql) { + Fixture.sql = sql + } + + /** + * テストフィクスチャをセットアップする + */ + @JvmStatic + fun setup(entity: T): T { + require(entity != null) { + "entity must not be null" + } + + sql.dataSet(extractTableName(entity)).add(extractColumns(entity)) + return entity + } + + /** + * テストフィクスチャをセットアップする + */ + @JvmStatic + fun setup(vararg entities: T): List { + entities.forEach { setup(it) } + return entities.toList() + } + + private fun extractTableName(entity: Any): String { + val tableName = CaseFormat.UPPER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, entity.javaClass.simpleName).replace("_entity", "") + return "`$tableName`" + } + + private fun extractColumns(entity: Any): Map { + val result = LinkedHashMap() + entity.javaClass.declaredFields.forEach { + val columnName = CaseFormat.UPPER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, it.name) + it.isAccessible = true + result["`$columnName`"] = it[entity] + it.isAccessible = false + } + return result + } + } +} diff --git a/testkit/src/main/kotlin/net/averak/gsync/testkit/IRandomizer.kt b/testkit/src/main/kotlin/net/averak/gsync/testkit/IRandomizer.kt new file mode 100644 index 0000000..5b89296 --- /dev/null +++ b/testkit/src/main/kotlin/net/averak/gsync/testkit/IRandomizer.kt @@ -0,0 +1,14 @@ +package net.averak.gsync.testkit + +import org.jeasy.random.api.Randomizer + +/** + * 任意の型の各フィールドにランダム値を格納したインスタンスを生成するための生成ルールセット + * ドメイン制約やDB制約に準拠したオブジェクトを生成したい場合に定義すること + */ +interface IRandomizer : Randomizer { + + override fun getRandomValue(): T + + fun getTypeToGenerate(): Class +}