feat: トランザクション管理を抽象化

This commit is contained in:
usbharu 2023-08-11 14:23:52 +09:00
parent 0d346b7368
commit ee8bbf5091
14 changed files with 79 additions and 52 deletions

View File

@ -21,13 +21,9 @@ import dev.usbharu.hideout.service.api.IPostApiService
import dev.usbharu.hideout.service.api.IUserApiService import dev.usbharu.hideout.service.api.IUserApiService
import dev.usbharu.hideout.service.api.UserAuthApiService import dev.usbharu.hideout.service.api.UserAuthApiService
import dev.usbharu.hideout.service.auth.HttpSignatureVerifyService import dev.usbharu.hideout.service.auth.HttpSignatureVerifyService
import dev.usbharu.hideout.service.core.IMetaService import dev.usbharu.hideout.service.core.*
import dev.usbharu.hideout.service.core.IServerInitialiseService
import dev.usbharu.hideout.service.core.IdGenerateService
import dev.usbharu.hideout.service.core.TwitterSnowflakeIdGenerateService
import dev.usbharu.hideout.service.job.JobQueueParentService import dev.usbharu.hideout.service.job.JobQueueParentService
import dev.usbharu.hideout.service.job.KJobJobQueueParentService import dev.usbharu.hideout.service.job.KJobJobQueueParentService
import dev.usbharu.hideout.service.reaction.IReactionService
import dev.usbharu.hideout.service.user.IUserService import dev.usbharu.hideout.service.user.IUserService
import dev.usbharu.kjob.exposed.ExposedKJob import dev.usbharu.kjob.exposed.ExposedKJob
import io.ktor.client.* import io.ktor.client.*
@ -117,10 +113,10 @@ fun Application.parent() {
activityPubUserService = inject<ActivityPubUserService>().value, activityPubUserService = inject<ActivityPubUserService>().value,
postService = inject<IPostApiService>().value, postService = inject<IPostApiService>().value,
userApiService = inject<IUserApiService>().value, userApiService = inject<IUserApiService>().value,
reactionService = inject<IReactionService>().value,
userQueryService = inject<UserQueryService>().value, userQueryService = inject<UserQueryService>().value,
followerQueryService = inject<FollowerQueryService>().value, followerQueryService = inject<FollowerQueryService>().value,
userAuthApiService = inject<UserAuthApiService>().value userAuthApiService = inject<UserAuthApiService>().value,
transaction = inject<Transaction>().value
) )
} }

View File

@ -15,7 +15,7 @@ import dev.usbharu.hideout.service.api.IPostApiService
import dev.usbharu.hideout.service.api.IUserApiService import dev.usbharu.hideout.service.api.IUserApiService
import dev.usbharu.hideout.service.api.UserAuthApiService import dev.usbharu.hideout.service.api.UserAuthApiService
import dev.usbharu.hideout.service.auth.HttpSignatureVerifyService import dev.usbharu.hideout.service.auth.HttpSignatureVerifyService
import dev.usbharu.hideout.service.reaction.IReactionService import dev.usbharu.hideout.service.core.Transaction
import dev.usbharu.hideout.service.user.IUserService import dev.usbharu.hideout.service.user.IUserService
import io.ktor.server.application.* import io.ktor.server.application.*
import io.ktor.server.plugins.autohead.* import io.ktor.server.plugins.autohead.*
@ -29,16 +29,16 @@ fun Application.configureRouting(
activityPubUserService: ActivityPubUserService, activityPubUserService: ActivityPubUserService,
postService: IPostApiService, postService: IPostApiService,
userApiService: IUserApiService, userApiService: IUserApiService,
reactionService: IReactionService,
userQueryService: UserQueryService, userQueryService: UserQueryService,
followerQueryService: FollowerQueryService, followerQueryService: FollowerQueryService,
userAuthApiService: UserAuthApiService userAuthApiService: UserAuthApiService,
transaction: Transaction
) { ) {
install(AutoHeadResponse) install(AutoHeadResponse)
routing { routing {
inbox(httpSignatureVerifyService, activityPubService) inbox(httpSignatureVerifyService, activityPubService)
outbox() outbox()
usersAP(activityPubUserService, userQueryService, followerQueryService) usersAP(activityPubUserService, userQueryService, followerQueryService, transaction)
webfinger(userQueryService) webfinger(userQueryService)
route("/api/internal/v1") { route("/api/internal/v1") {
posts(postService) posts(postService)

View File

@ -6,6 +6,7 @@ import dev.usbharu.hideout.plugins.respondAp
import dev.usbharu.hideout.query.FollowerQueryService import dev.usbharu.hideout.query.FollowerQueryService
import dev.usbharu.hideout.query.UserQueryService import dev.usbharu.hideout.query.UserQueryService
import dev.usbharu.hideout.service.activitypub.ActivityPubUserService import dev.usbharu.hideout.service.activitypub.ActivityPubUserService
import dev.usbharu.hideout.service.core.Transaction
import dev.usbharu.hideout.util.HttpUtil.Activity import dev.usbharu.hideout.util.HttpUtil.Activity
import dev.usbharu.hideout.util.HttpUtil.JsonLd import dev.usbharu.hideout.util.HttpUtil.JsonLd
import io.ktor.http.* import io.ktor.http.*
@ -13,12 +14,12 @@ import io.ktor.server.application.*
import io.ktor.server.request.* import io.ktor.server.request.*
import io.ktor.server.response.* import io.ktor.server.response.*
import io.ktor.server.routing.* import io.ktor.server.routing.*
import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction
fun Routing.usersAP( fun Routing.usersAP(
activityPubUserService: ActivityPubUserService, activityPubUserService: ActivityPubUserService,
userQueryService: UserQueryService, userQueryService: UserQueryService,
followerQueryService: FollowerQueryService followerQueryService: FollowerQueryService,
transaction: Transaction
) { ) {
route("/users/{name}") { route("/users/{name}") {
createChild(ContentTypeRouteSelector(ContentType.Application.Activity, ContentType.Application.JsonLd)).handle { createChild(ContentTypeRouteSelector(ContentType.Application.Activity, ContentType.Application.JsonLd)).handle {
@ -34,7 +35,7 @@ fun Routing.usersAP(
} }
get { get {
// TODO: 暫定処置なので治す // TODO: 暫定処置なので治す
newSuspendedTransaction { transaction.transaction {
val userEntity = userQueryService.findByNameAndDomain( val userEntity = userQueryService.findByNameAndDomain(
call.parameters["name"] call.parameters["name"]
?: throw ParameterNotExistException("Parameter(name='name') does not exist."), ?: throw ParameterNotExistException("Parameter(name='name') does not exist."),

View File

@ -7,10 +7,10 @@ import dev.usbharu.hideout.domain.model.hideout.dto.ReactionResponse
import dev.usbharu.hideout.query.PostResponseQueryService import dev.usbharu.hideout.query.PostResponseQueryService
import dev.usbharu.hideout.query.ReactionQueryService import dev.usbharu.hideout.query.ReactionQueryService
import dev.usbharu.hideout.repository.IUserRepository import dev.usbharu.hideout.repository.IUserRepository
import dev.usbharu.hideout.service.core.Transaction
import dev.usbharu.hideout.service.post.IPostService import dev.usbharu.hideout.service.post.IPostService
import dev.usbharu.hideout.service.reaction.IReactionService import dev.usbharu.hideout.service.reaction.IReactionService
import dev.usbharu.hideout.util.AcctUtil import dev.usbharu.hideout.util.AcctUtil
import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction
import org.koin.core.annotation.Single import org.koin.core.annotation.Single
import java.time.Instant import java.time.Instant
import dev.usbharu.hideout.domain.model.hideout.form.Post as FormPost import dev.usbharu.hideout.domain.model.hideout.form.Post as FormPost
@ -21,10 +21,11 @@ class PostApiServiceImpl(
private val userRepository: IUserRepository, private val userRepository: IUserRepository,
private val postResponseQueryService: PostResponseQueryService, private val postResponseQueryService: PostResponseQueryService,
private val reactionQueryService: ReactionQueryService, private val reactionQueryService: ReactionQueryService,
private val reactionService: IReactionService private val reactionService: IReactionService,
private val transaction: Transaction
) : IPostApiService { ) : IPostApiService {
override suspend fun createPost(postForm: FormPost, userId: Long): PostResponse { override suspend fun createPost(postForm: FormPost, userId: Long): PostResponse {
return newSuspendedTransaction { return transaction.transaction {
val createdPost = postService.createLocal( val createdPost = postService.createLocal(
PostCreateDto( PostCreateDto(
text = postForm.text, text = postForm.text,
@ -49,7 +50,7 @@ class PostApiServiceImpl(
maxId: Long?, maxId: Long?,
limit: Int?, limit: Int?,
userId: Long? userId: Long?
): List<PostResponse> = newSuspendedTransaction { ): List<PostResponse> = transaction.transaction {
postResponseQueryService.findAll( postResponseQueryService.findAll(
since?.toEpochMilli(), since?.toEpochMilli(),
until?.toEpochMilli(), until?.toEpochMilli(),
@ -79,16 +80,16 @@ class PostApiServiceImpl(
} }
override suspend fun getReactionByPostId(postId: Long, userId: Long?): List<ReactionResponse> = override suspend fun getReactionByPostId(postId: Long, userId: Long?): List<ReactionResponse> =
newSuspendedTransaction { reactionQueryService.findByPostIdWithUsers(postId, userId) } transaction.transaction { reactionQueryService.findByPostIdWithUsers(postId, userId) }
override suspend fun appendReaction(reaction: String, userId: Long, postId: Long) { override suspend fun appendReaction(reaction: String, userId: Long, postId: Long) {
newSuspendedTransaction { transaction.transaction {
reactionService.sendReaction(reaction, userId, postId) reactionService.sendReaction(reaction, userId, postId)
} }
} }
override suspend fun removeReaction(userId: Long, postId: Long) { override suspend fun removeReaction(userId: Long, postId: Long) {
newSuspendedTransaction { transaction.transaction {
reactionService.removeReaction(userId, postId) reactionService.removeReaction(userId, postId)
} }
} }

View File

@ -7,8 +7,8 @@ import dev.usbharu.hideout.domain.model.hideout.dto.UserResponse
import dev.usbharu.hideout.exception.UsernameAlreadyExistException import dev.usbharu.hideout.exception.UsernameAlreadyExistException
import dev.usbharu.hideout.query.FollowerQueryService import dev.usbharu.hideout.query.FollowerQueryService
import dev.usbharu.hideout.query.UserQueryService import dev.usbharu.hideout.query.UserQueryService
import dev.usbharu.hideout.service.core.Transaction
import dev.usbharu.hideout.service.user.IUserService import dev.usbharu.hideout.service.user.IUserService
import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction
import org.koin.core.annotation.Single import org.koin.core.annotation.Single
import kotlin.math.min import kotlin.math.min
@ -16,7 +16,8 @@ import kotlin.math.min
class UserApiServiceImpl( class UserApiServiceImpl(
private val userQueryService: UserQueryService, private val userQueryService: UserQueryService,
private val followerQueryService: FollowerQueryService, private val followerQueryService: FollowerQueryService,
private val userService: IUserService private val userService: IUserService,
private val transaction: Transaction
) : IUserApiService { ) : IUserApiService {
override suspend fun findAll(limit: Int?, offset: Long): List<UserResponse> = override suspend fun findAll(limit: Int?, offset: Long): List<UserResponse> =
userQueryService.findAll(min(limit ?: 100, 100), offset).map { UserResponse.from(it) } userQueryService.findAll(min(limit ?: 100, 100), offset).map { UserResponse.from(it) }
@ -44,7 +45,7 @@ class UserApiServiceImpl(
.map { UserResponse.from(it) } .map { UserResponse.from(it) }
override suspend fun createUser(username: String, password: String): UserResponse { override suspend fun createUser(username: String, password: String): UserResponse {
return newSuspendedTransaction { return transaction.transaction {
if (userQueryService.existByNameAndDomain(username, Config.configData.domain)) { if (userQueryService.existByNameAndDomain(username, Config.configData.domain)) {
throw UsernameAlreadyExistException() throw UsernameAlreadyExistException()
} }

View File

@ -6,18 +6,19 @@ import dev.usbharu.hideout.domain.model.hideout.form.RefreshToken
import dev.usbharu.hideout.exception.InvalidUsernameOrPasswordException import dev.usbharu.hideout.exception.InvalidUsernameOrPasswordException
import dev.usbharu.hideout.query.UserQueryService import dev.usbharu.hideout.query.UserQueryService
import dev.usbharu.hideout.service.auth.IJwtService import dev.usbharu.hideout.service.auth.IJwtService
import dev.usbharu.hideout.service.core.Transaction
import dev.usbharu.hideout.service.user.UserAuthService import dev.usbharu.hideout.service.user.UserAuthService
import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction
import org.koin.core.annotation.Single import org.koin.core.annotation.Single
@Single @Single
class UserAuthApiServiceImpl( class UserAuthApiServiceImpl(
private val userAuthService: UserAuthService, private val userAuthService: UserAuthService,
private val userQueryService: UserQueryService, private val userQueryService: UserQueryService,
private val jwtService: IJwtService private val jwtService: IJwtService,
private val transaction: Transaction
) : UserAuthApiService { ) : UserAuthApiService {
override suspend fun login(username: String, password: String): JwtToken { override suspend fun login(username: String, password: String): JwtToken {
return newSuspendedTransaction { return transaction.transaction {
if (userAuthService.verifyAccount(username, password).not()) { if (userAuthService.verifyAccount(username, password).not()) {
throw InvalidUsernameOrPasswordException() throw InvalidUsernameOrPasswordException()
} }
@ -27,7 +28,7 @@ class UserAuthApiServiceImpl(
} }
override suspend fun refreshToken(refreshToken: RefreshToken): JwtToken { override suspend fun refreshToken(refreshToken: RefreshToken): JwtToken {
return newSuspendedTransaction { return transaction.transaction {
jwtService.refreshToken(refreshToken) jwtService.refreshToken(refreshToken)
} }
} }

View File

@ -0,0 +1,13 @@
package dev.usbharu.hideout.service.core
import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction
import org.koin.core.annotation.Single
@Single
class ExposedTransaction : Transaction {
override suspend fun <T> transaction(block: suspend () -> T): T {
return newSuspendedTransaction {
block()
}
}
}

View File

@ -4,15 +4,15 @@ import dev.usbharu.hideout.domain.model.hideout.entity.Jwt
import dev.usbharu.hideout.domain.model.hideout.entity.Meta import dev.usbharu.hideout.domain.model.hideout.entity.Meta
import dev.usbharu.hideout.exception.NotInitException import dev.usbharu.hideout.exception.NotInitException
import dev.usbharu.hideout.repository.IMetaRepository import dev.usbharu.hideout.repository.IMetaRepository
import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction
import org.koin.core.annotation.Single import org.koin.core.annotation.Single
@Single @Single
class MetaServiceImpl(private val metaRepository: IMetaRepository) : IMetaService { class MetaServiceImpl(private val metaRepository: IMetaRepository, private val transaction: Transaction) :
IMetaService {
override suspend fun getMeta(): Meta = override suspend fun getMeta(): Meta =
newSuspendedTransaction { metaRepository.get() ?: throw NotInitException("Meta is null") } transaction.transaction { metaRepository.get() ?: throw NotInitException("Meta is null") }
override suspend fun updateMeta(meta: Meta) = newSuspendedTransaction { override suspend fun updateMeta(meta: Meta) = transaction.transaction {
metaRepository.save(meta) metaRepository.save(meta)
} }

View File

@ -4,7 +4,6 @@ import dev.usbharu.hideout.domain.model.hideout.entity.Jwt
import dev.usbharu.hideout.domain.model.hideout.entity.Meta import dev.usbharu.hideout.domain.model.hideout.entity.Meta
import dev.usbharu.hideout.repository.IMetaRepository import dev.usbharu.hideout.repository.IMetaRepository
import dev.usbharu.hideout.util.ServerUtil import dev.usbharu.hideout.util.ServerUtil
import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction
import org.koin.core.annotation.Single import org.koin.core.annotation.Single
import org.slf4j.Logger import org.slf4j.Logger
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
@ -12,19 +11,23 @@ import java.security.KeyPairGenerator
import java.util.* import java.util.*
@Single @Single
class ServerInitialiseServiceImpl(private val metaRepository: IMetaRepository) : IServerInitialiseService { class ServerInitialiseServiceImpl(
private val metaRepository: IMetaRepository,
private val transaction: Transaction
) :
IServerInitialiseService {
val logger: Logger = LoggerFactory.getLogger(ServerInitialiseServiceImpl::class.java) val logger: Logger = LoggerFactory.getLogger(ServerInitialiseServiceImpl::class.java)
override suspend fun init() { override suspend fun init() {
newSuspendedTransaction { transaction.transaction {
val savedMeta = metaRepository.get() val savedMeta = metaRepository.get()
val implementationVersion = ServerUtil.getImplementationVersion() val implementationVersion = ServerUtil.getImplementationVersion()
if (wasInitialised(savedMeta).not()) { if (wasInitialised(savedMeta).not()) {
logger.info("Start Initialise") logger.info("Start Initialise")
initialise(implementationVersion) initialise(implementationVersion)
logger.info("Finish Initialise") logger.info("Finish Initialise")
return@newSuspendedTransaction return@transaction
} }
if (isVersionChanged(requireNotNull(savedMeta))) { if (isVersionChanged(requireNotNull(savedMeta))) {

View File

@ -0,0 +1,5 @@
package dev.usbharu.hideout.service.core
interface Transaction {
suspend fun <T> transaction(block: suspend () -> T): T
}

View File

@ -26,6 +26,7 @@ import org.mockito.ArgumentMatchers.anyString
import org.mockito.kotlin.doReturn import org.mockito.kotlin.doReturn
import org.mockito.kotlin.eq import org.mockito.kotlin.eq
import org.mockito.kotlin.mock import org.mockito.kotlin.mock
import utils.TestTransaction
import java.time.Instant import java.time.Instant
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertTrue import kotlin.test.assertTrue
@ -69,7 +70,7 @@ class UsersAPTest {
application { application {
configureSerialization() configureSerialization()
routing { routing {
usersAP(activityPubUserService, mock(), mock()) usersAP(activityPubUserService, mock(), mock(), TestTransaction)
} }
} }
client.get("/users/test") { client.get("/users/test") {
@ -127,7 +128,7 @@ class UsersAPTest {
application { application {
configureSerialization() configureSerialization()
routing { routing {
usersAP(activityPubUserService, mock(), mock()) usersAP(activityPubUserService, mock(), mock(), TestTransaction)
} }
} }
client.get("/users/test") { client.get("/users/test") {
@ -188,7 +189,7 @@ class UsersAPTest {
} }
application { application {
routing { routing {
usersAP(mock(), userService, mock()) usersAP(mock(), userService, mock(), TestTransaction)
} }
} }
client.get("/users/test") { client.get("/users/test") {

View File

@ -11,6 +11,7 @@ import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.assertThrows
import org.mockito.kotlin.* import org.mockito.kotlin.*
import utils.TestTransaction
import java.util.* import java.util.*
import kotlin.test.assertEquals import kotlin.test.assertEquals
@ -21,7 +22,7 @@ class MetaServiceImplTest {
val metaRepository = mock<IMetaRepository> { val metaRepository = mock<IMetaRepository> {
onBlocking { get() } doReturn meta onBlocking { get() } doReturn meta
} }
val metaService = MetaServiceImpl(metaRepository) val metaService = MetaServiceImpl(metaRepository, TestTransaction)
val actual = metaService.getMeta() val actual = metaService.getMeta()
assertEquals(meta, actual) assertEquals(meta, actual)
} }
@ -31,7 +32,7 @@ class MetaServiceImplTest {
val metaRepository = mock<IMetaRepository> { val metaRepository = mock<IMetaRepository> {
onBlocking { get() } doReturn null onBlocking { get() } doReturn null
} }
val metaService = MetaServiceImpl(metaRepository) val metaService = MetaServiceImpl(metaRepository, TestTransaction)
assertThrows<NotInitException> { metaService.getMeta() } assertThrows<NotInitException> { metaService.getMeta() }
} }
@ -41,7 +42,7 @@ class MetaServiceImplTest {
val metaRepository = mock<IMetaRepository> { val metaRepository = mock<IMetaRepository> {
onBlocking { save(any()) } doReturn Unit onBlocking { save(any()) } doReturn Unit
} }
val metaServiceImpl = MetaServiceImpl(metaRepository) val metaServiceImpl = MetaServiceImpl(metaRepository, TestTransaction)
metaServiceImpl.updateMeta(meta) metaServiceImpl.updateMeta(meta)
argumentCaptor<Meta> { argumentCaptor<Meta> {
verify(metaRepository).save(capture()) verify(metaRepository).save(capture())
@ -55,7 +56,7 @@ class MetaServiceImplTest {
val metaRepository = mock<IMetaRepository> { val metaRepository = mock<IMetaRepository> {
onBlocking { get() } doReturn meta onBlocking { get() } doReturn meta
} }
val metaService = MetaServiceImpl(metaRepository) val metaService = MetaServiceImpl(metaRepository, TestTransaction)
val actual = metaService.getJwtMeta() val actual = metaService.getJwtMeta()
assertEquals(meta.jwt, actual) assertEquals(meta.jwt, actual)
} }
@ -65,11 +66,7 @@ class MetaServiceImplTest {
val metaRepository = mock<IMetaRepository> { val metaRepository = mock<IMetaRepository> {
onBlocking { get() } doReturn null onBlocking { get() } doReturn null
} }
val metaService = MetaServiceImpl(metaRepository) val metaService = MetaServiceImpl(metaRepository, TestTransaction)
try { assertThrows<NotInitException> { metaService.getJwtMeta() }
// assertThrows<NotInitException> { metaService.getJwtMeta() }
} catch (e: Exception) {
e.printStackTrace()
}
} }
} }

View File

@ -10,6 +10,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.mockito.kotlin.* import org.mockito.kotlin.*
import utils.TestTransaction
import java.util.* import java.util.*
import kotlin.test.assertEquals import kotlin.test.assertEquals
@ -20,7 +21,7 @@ class ServerInitialiseServiceImplTest {
onBlocking { get() } doReturn null onBlocking { get() } doReturn null
onBlocking { save(any()) } doReturn Unit onBlocking { save(any()) } doReturn Unit
} }
val serverInitialiseServiceImpl = ServerInitialiseServiceImpl(metaRepository) val serverInitialiseServiceImpl = ServerInitialiseServiceImpl(metaRepository, TestTransaction)
serverInitialiseServiceImpl.init() serverInitialiseServiceImpl.init()
verify(metaRepository, times(1)).save(any()) verify(metaRepository, times(1)).save(any())
@ -32,7 +33,7 @@ class ServerInitialiseServiceImplTest {
val metaRepository = mock<IMetaRepository> { val metaRepository = mock<IMetaRepository> {
onBlocking { get() } doReturn meta onBlocking { get() } doReturn meta
} }
val serverInitialiseServiceImpl = ServerInitialiseServiceImpl(metaRepository) val serverInitialiseServiceImpl = ServerInitialiseServiceImpl(metaRepository, TestTransaction)
serverInitialiseServiceImpl.init() serverInitialiseServiceImpl.init()
verify(metaRepository, times(0)).save(any()) verify(metaRepository, times(0)).save(any())
} }
@ -45,7 +46,7 @@ class ServerInitialiseServiceImplTest {
onBlocking { save(any()) } doReturn Unit onBlocking { save(any()) } doReturn Unit
} }
val serverInitialiseServiceImpl = ServerInitialiseServiceImpl(metaRepository) val serverInitialiseServiceImpl = ServerInitialiseServiceImpl(metaRepository, TestTransaction)
serverInitialiseServiceImpl.init() serverInitialiseServiceImpl.init()
verify(metaRepository, times(1)).save(any()) verify(metaRepository, times(1)).save(any())
argumentCaptor<Meta> { argumentCaptor<Meta> {

View File

@ -0,0 +1,7 @@
package utils
import dev.usbharu.hideout.service.core.Transaction
object TestTransaction : Transaction {
override suspend fun <T> transaction(block: suspend () -> T): T = block()
}