diff --git a/app/src/main/kotlin/io/sakurasou/controller/request/UserRequest.kt b/app/src/main/kotlin/io/sakurasou/controller/request/UserRequest.kt index 5f474230..063fcb0e 100644 --- a/app/src/main/kotlin/io/sakurasou/controller/request/UserRequest.kt +++ b/app/src/main/kotlin/io/sakurasou/controller/request/UserRequest.kt @@ -43,5 +43,4 @@ data class UserManageInsertRequest( val password: String, val email: String, val isDefaultImagePrivate: Boolean, - val defaultAlbumId: Long ) \ No newline at end of file diff --git a/app/src/main/kotlin/io/sakurasou/exception/service/user/UserDeleteFailedException.kt b/app/src/main/kotlin/io/sakurasou/exception/service/user/UserDeleteFailedException.kt new file mode 100644 index 00000000..697f4b22 --- /dev/null +++ b/app/src/main/kotlin/io/sakurasou/exception/service/user/UserDeleteFailedException.kt @@ -0,0 +1,19 @@ +package io.sakurasou.exception.service.user + +import io.sakurasou.exception.ServiceThrowable + +/** + * @author Shiina Kin + * 2024/9/25 12:44 + */ +class UserDeleteFailedException( + cause: ServiceThrowable? = null +) : ServiceThrowable() { + override val code: Int + get() = 4000 + override var message: String = "User Delete Failed" + + init { + message = (message + (cause?.let { ", " + it.message } ?: "")) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/sakurasou/exception/service/user/UserInsertFailedException.kt b/app/src/main/kotlin/io/sakurasou/exception/service/user/UserInsertFailedException.kt new file mode 100644 index 00000000..e752d8b9 --- /dev/null +++ b/app/src/main/kotlin/io/sakurasou/exception/service/user/UserInsertFailedException.kt @@ -0,0 +1,20 @@ +package io.sakurasou.exception.service.user + +import io.sakurasou.exception.ServiceThrowable + +/** + * @author Shiina Kin + * 2024/9/25 12:44 + */ +class UserInsertFailedException( + cause: ServiceThrowable? = null, + reason: String? = null +) : ServiceThrowable() { + override val code: Int + get() = 4000 + override var message: String = "User Insert Failed" + + init { + message += if (cause != null) ", ${cause.message}" else if (reason != null) ", $reason" else "" + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/sakurasou/exception/service/user/UserUpdateFailedException.kt b/app/src/main/kotlin/io/sakurasou/exception/service/user/UserUpdateFailedException.kt new file mode 100644 index 00000000..aa6f9b6f --- /dev/null +++ b/app/src/main/kotlin/io/sakurasou/exception/service/user/UserUpdateFailedException.kt @@ -0,0 +1,19 @@ +package io.sakurasou.exception.service.user + +import io.sakurasou.exception.ServiceThrowable + +/** + * @author Shiina Kin + * 2024/9/25 12:44 + */ +class UserUpdateFailedException( + cause: ServiceThrowable? = null +) : ServiceThrowable() { + override val code: Int + get() = 4000 + override var message: String = "User Update Failed" + + init { + message = (message + (cause?.let { ", " + it.message } ?: "")) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/sakurasou/service/user/UserService.kt b/app/src/main/kotlin/io/sakurasou/service/user/UserService.kt index eff197c3..5551514f 100644 --- a/app/src/main/kotlin/io/sakurasou/service/user/UserService.kt +++ b/app/src/main/kotlin/io/sakurasou/service/user/UserService.kt @@ -1,6 +1,9 @@ package io.sakurasou.service.user -import io.sakurasou.controller.request.UserInsertRequest +import io.sakurasou.controller.request.* +import io.sakurasou.controller.vo.PageResult +import io.sakurasou.controller.vo.UserPageVO +import io.sakurasou.controller.vo.UserVO /** * @author ShiinaKin @@ -8,4 +11,12 @@ import io.sakurasou.controller.request.UserInsertRequest */ interface UserService { suspend fun saveUser(userInsertRequest: UserInsertRequest) + suspend fun saveUserManually(userManageInsertRequest: UserManageInsertRequest) + suspend fun deleteUser(id: Long) + suspend fun patchSelf(id: Long, patchRequest: UserSelfPatchRequest) + suspend fun patchUser(id: Long, patchRequest: UserManagePatchRequest) + suspend fun banUser(id: Long) + suspend fun unbanUser(id: Long) + suspend fun fetchUser(id: Long): UserVO + suspend fun pageUsers(pageRequest: PageRequest): PageResult } \ No newline at end of file diff --git a/app/src/main/kotlin/io/sakurasou/service/user/UserServiceImpl.kt b/app/src/main/kotlin/io/sakurasou/service/user/UserServiceImpl.kt index 5b645254..7aa949c8 100644 --- a/app/src/main/kotlin/io/sakurasou/service/user/UserServiceImpl.kt +++ b/app/src/main/kotlin/io/sakurasou/service/user/UserServiceImpl.kt @@ -1,14 +1,23 @@ package io.sakurasou.service.user import at.favre.lib.crypto.bcrypt.BCrypt -import io.sakurasou.controller.request.UserInsertRequest +import io.sakurasou.controller.request.* +import io.sakurasou.controller.vo.PageResult +import io.sakurasou.controller.vo.UserPageVO +import io.sakurasou.controller.vo.UserVO import io.sakurasou.exception.controller.access.SignupNotAllowedException +import io.sakurasou.exception.service.user.UserDeleteFailedException +import io.sakurasou.exception.service.user.UserInsertFailedException +import io.sakurasou.exception.service.user.UserNotFoundException +import io.sakurasou.exception.service.user.UserUpdateFailedException import io.sakurasou.model.DatabaseSingleton.dbQuery import io.sakurasou.model.dao.album.AlbumDao import io.sakurasou.model.dao.group.GroupDao import io.sakurasou.model.dao.image.ImageDao import io.sakurasou.model.dao.user.UserDao import io.sakurasou.model.dto.UserInsertDTO +import io.sakurasou.model.dto.UserManageUpdateDTO +import io.sakurasou.model.dto.UserSelfUpdateDTO import io.sakurasou.service.setting.SettingService import kotlinx.datetime.Clock import kotlinx.datetime.TimeZone @@ -51,4 +60,151 @@ class UserServiceImpl( } } + override suspend fun saveUserManually(userManageInsertRequest: UserManageInsertRequest) { + val rawPassword = userManageInsertRequest.password + val encodePassword = BCrypt.withDefaults().hashToString(12, rawPassword.toCharArray()) + + val now = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()) + + val userInsertDTO = UserInsertDTO( + groupId = userManageInsertRequest.groupId, + username = userManageInsertRequest.username, + password = encodePassword, + email = userManageInsertRequest.email, + isDefaultImagePrivate = userManageInsertRequest.isDefaultImagePrivate, + defaultAlbumId = null, + isBanned = false, + updateTime = now, + createTime = now + ) + runCatching { + dbQuery { + val userId = userDao.saveUser(userInsertDTO) + val defaultAlbumId = albumDao.initAlbumForUser(userId) + userDao.updateUserDefaultAlbumId(userId, defaultAlbumId) + Unit + } + }.onFailure { + throw UserInsertFailedException(null, "Possibly due to duplicate Username") + } + } + + override suspend fun deleteUser(id: Long) { + runCatching { + dbQuery { + val influenceRowCnt = userDao.deleteUserById(id) + if (influenceRowCnt < 1) throw UserNotFoundException() + } + }.onFailure { + if (it is UserNotFoundException) throw UserDeleteFailedException(it) + else throw it + } + } + + override suspend fun patchSelf(id: Long, patchRequest: UserSelfPatchRequest) { + dbQuery { + val oldUserInfo = userDao.findUserById(id) ?: throw UserNotFoundException() + + val encodePassword = patchRequest.password?.let { + BCrypt.withDefaults().hashToString(12, it.toCharArray()) + } ?: oldUserInfo.password + val now = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()) + + val selfUpdateDTO = UserSelfUpdateDTO( + id = id, + password = encodePassword, + email = patchRequest.email ?: oldUserInfo.email, + isDefaultImagePrivate = patchRequest.isDefaultImagePrivate ?: oldUserInfo.isDefaultImagePrivate, + defaultAlbumId = patchRequest.defaultAlbumId ?: oldUserInfo.defaultAlbumId, + updateTime = now + ) + + runCatching { + val influenceRowCnt = userDao.updateSelfById(selfUpdateDTO) + if (influenceRowCnt < 1) throw UserNotFoundException() + }.onFailure { + if (it is UserNotFoundException) throw UserUpdateFailedException(it) + else throw it + } + } + } + + override suspend fun patchUser(id: Long, patchRequest: UserManagePatchRequest) { + dbQuery { + val oldUserInfo = userDao.findUserById(id) ?: throw UserNotFoundException() + + val encodePassword = patchRequest.password?.let { + BCrypt.withDefaults().hashToString(12, it.toCharArray()) + } ?: oldUserInfo.password + val now = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()) + + val userUpdateDTO = UserManageUpdateDTO( + id = id, + groupId = patchRequest.groupId ?: oldUserInfo.groupId, + password = encodePassword, + email = patchRequest.email ?: oldUserInfo.email, + isDefaultImagePrivate = patchRequest.isDefaultImagePrivate ?: oldUserInfo.isDefaultImagePrivate, + defaultAlbumId = patchRequest.defaultAlbumId ?: oldUserInfo.defaultAlbumId, + updateTime = now + ) + + val isModifyGroup = patchRequest.groupId != null + + runCatching { + val influenceRowCnt = userDao.updateUserById(userUpdateDTO) + if (influenceRowCnt < 1) throw UserNotFoundException() + if (isModifyGroup) imageDao.updateImageGroupIdByUserId(id, userUpdateDTO.groupId) + }.onFailure { + if (it is UserNotFoundException) throw UserUpdateFailedException(it) + else throw it + } + } + } + + override suspend fun banUser(id: Long) { + runCatching { + dbQuery { + val influenceRowCnt = userDao.updateUserBanStatusById(id, true) + if (influenceRowCnt < 1) throw UserNotFoundException() + } + }.onFailure { + if (it is UserNotFoundException) throw UserUpdateFailedException(it) + else throw it + } + } + + override suspend fun unbanUser(id: Long) { + runCatching { + dbQuery { + val influenceRowCnt = userDao.updateUserBanStatusById(id, false) + if (influenceRowCnt < 1) throw UserNotFoundException() + } + }.onFailure { + if (it is UserNotFoundException) throw UserUpdateFailedException(it) + else throw it + } + } + + override suspend fun fetchUser(id: Long): UserVO { + return dbQuery { + val user = userDao.findUserById(id) ?: throw UserNotFoundException() + val group = groupDao.findGroupById(user.groupId)!! + val (count, totalSize) = imageDao.getImageCountAndTotalSizeOfUser(id) + UserVO( + id = user.id, + username = user.name, + groupName = group.name, + email = user.email, + isDefaultImagePrivate = user.isDefaultImagePrivate, + isBanned = user.isBanned, + createTime = user.createTime, + imageCount = count, + totalImageSize = totalSize + ) + } + } + + override suspend fun pageUsers(pageRequest: PageRequest): PageResult { + return dbQuery { userDao.pagination(pageRequest) } + } } \ No newline at end of file diff --git a/app/src/test/kotlin/io/sakurasou/service/user/UserServiceTest.kt b/app/src/test/kotlin/io/sakurasou/service/user/UserServiceTest.kt index cbe8a957..19e76b39 100644 --- a/app/src/test/kotlin/io/sakurasou/service/user/UserServiceTest.kt +++ b/app/src/test/kotlin/io/sakurasou/service/user/UserServiceTest.kt @@ -3,21 +3,33 @@ package io.sakurasou.service.user import at.favre.lib.crypto.bcrypt.BCrypt import io.mockk.* import io.sakurasou.controller.request.UserInsertRequest +import io.sakurasou.controller.request.UserManageInsertRequest +import io.sakurasou.controller.request.UserManagePatchRequest +import io.sakurasou.controller.request.UserSelfPatchRequest +import io.sakurasou.controller.vo.UserVO import io.sakurasou.exception.controller.access.SignupNotAllowedException +import io.sakurasou.exception.service.user.UserDeleteFailedException +import io.sakurasou.exception.service.user.UserInsertFailedException +import io.sakurasou.exception.service.user.UserNotFoundException +import io.sakurasou.exception.service.user.UserUpdateFailedException import io.sakurasou.model.DatabaseSingleton import io.sakurasou.model.dao.album.AlbumDao import io.sakurasou.model.dao.group.GroupDao import io.sakurasou.model.dao.image.ImageDao import io.sakurasou.model.dao.user.UserDao +import io.sakurasou.model.dto.ImageCountAndTotalSizeDTO import io.sakurasou.model.dto.UserInsertDTO +import io.sakurasou.model.dto.UserManageUpdateDTO +import io.sakurasou.model.dto.UserSelfUpdateDTO +import io.sakurasou.model.entity.Group +import io.sakurasou.model.entity.User import io.sakurasou.model.setting.SystemSetting import io.sakurasou.service.setting.SettingService import kotlinx.coroutines.runBlocking -import kotlinx.datetime.Clock -import kotlinx.datetime.TimeZone -import kotlinx.datetime.toLocalDateTime +import kotlinx.datetime.* import kotlin.test.BeforeTest import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertFailsWith /** @@ -31,6 +43,8 @@ class UserServiceTest { private lateinit var imageDao: ImageDao private lateinit var settingService: SettingService private lateinit var userService: UserServiceImpl + private lateinit var instant: Instant + private lateinit var now: LocalDateTime @BeforeTest fun setUp() { @@ -46,6 +60,9 @@ class UserServiceTest { coEvery { DatabaseSingleton.dbQuery(any()) } coAnswers { this.arg Unit>(0).invoke() } + instant = Clock.System.now() + every { Clock.System.now() } returns instant + now = instant.toLocalDateTime(TimeZone.currentSystemDefault()) } @Test @@ -79,8 +96,6 @@ class UserServiceTest { allowSignup = true ) - val instant = Clock.System.now() - val now = instant.toLocalDateTime(TimeZone.currentSystemDefault()) val encodedPassword = "encodedPassword" val userInsertDTO = UserInsertDTO( groupId = 2, @@ -94,7 +109,6 @@ class UserServiceTest { updateTime = now ) - every { Clock.System.now() } returns instant coEvery { settingService.getSystemSetting() } returns systemSetting every { BCrypt.withDefaults().hashToString(12, userInsertRequest.password.toCharArray()) @@ -110,4 +124,420 @@ class UserServiceTest { verify(exactly = 1) { albumDao.initAlbumForUser(1L) } verify(exactly = 1) { userDao.updateUserDefaultAlbumId(1L, 1L) } } + + @Test + fun `saveUserManually should be successful`() = runBlocking { + val userManageInsertRequest = UserManageInsertRequest( + groupId = 1, + username = "newUser", + password = "newPassword123", + email = "newuser@example.com", + isDefaultImagePrivate = false + ) + val encodedPassword = "encodedPassword123" + val userInsertDTO = UserInsertDTO( + groupId = userManageInsertRequest.groupId, + username = userManageInsertRequest.username, + password = encodedPassword, + email = userManageInsertRequest.email, + isDefaultImagePrivate = userManageInsertRequest.isDefaultImagePrivate, + defaultAlbumId = null, + isBanned = false, + createTime = now, + updateTime = now + ) + + every { + BCrypt.withDefaults().hashToString(12, userManageInsertRequest.password.toCharArray()) + } returns encodedPassword + every { userDao.saveUser(userInsertDTO) } returns 1L + every { albumDao.initAlbumForUser(1L) } returns 1L + every { userDao.updateUserDefaultAlbumId(1L, 1L) } returns 1 + + userService.saveUserManually(userManageInsertRequest) + + verify(exactly = 1) { userDao.saveUser(userInsertDTO) } + verify(exactly = 1) { albumDao.initAlbumForUser(1L) } + verify(exactly = 1) { userDao.updateUserDefaultAlbumId(1L, 1L) } + } + + @Test + fun `saveUserManually should throw UserInsertFailedException`() = runBlocking { + val userManageInsertRequest = UserManageInsertRequest( + groupId = 1, + username = "newUser", + password = "newPassword123", + email = "newuser@example.com", + isDefaultImagePrivate = false + ) + val encodedPassword = "encodedPassword123" + val userInsertDTO = UserInsertDTO( + groupId = userManageInsertRequest.groupId, + username = userManageInsertRequest.username, + password = encodedPassword, + email = userManageInsertRequest.email, + isDefaultImagePrivate = userManageInsertRequest.isDefaultImagePrivate, + defaultAlbumId = null, + isBanned = false, + createTime = now, + updateTime = now + ) + + every { + BCrypt.withDefaults().hashToString(12, userManageInsertRequest.password.toCharArray()) + } returns encodedPassword + every { userDao.saveUser(userInsertDTO) } throws RuntimeException("some database error") + + assertFailsWith { + userService.saveUserManually(userManageInsertRequest) + } + + verify(exactly = 1) { userDao.saveUser(userInsertDTO) } + verify(exactly = 0) { albumDao.initAlbumForUser(any()) } + verify(exactly = 0) { userDao.updateUserDefaultAlbumId(any(), any()) } + } + + @Test + fun `deleteUser should delete user successfully when user exists`() = runBlocking { + val userId = 1L + every { userDao.deleteUserById(userId) } returns 1 + + userService.deleteUser(userId) + + verify(exactly = 1) { userDao.deleteUserById(userId) } + } + + @Test + fun `deleteUser should throw UserDeleteFailedException when user not found`() = runBlocking { + val userId = 1L + every { userDao.deleteUserById(userId) } returns 0 + + assertFailsWith { + userService.deleteUser(userId) + } + + verify(exactly = 1) { userDao.deleteUserById(userId) } + } + + @Test + fun `patchSelf should update user successfully when all data is valid`() = runBlocking { + val userId = 1L + val oldUserInfo = User( + id = userId, + name = "oldUser", + password = "oldPassword", + email = "old@example.com", + isDefaultImagePrivate = false, + defaultAlbumId = 1, + groupId = 1, + isBanned = false, + updateTime = now, + createTime = now + ) + val patchRequest = UserSelfPatchRequest( + password = "newPassword", + email = "new@example.com", + isDefaultImagePrivate = true, + defaultAlbumId = 2 + ) + val encodedPassword = "encodedNewPassword" + + val exceptedUpdateDTO = UserSelfUpdateDTO( + id = userId, + password = encodedPassword, + email = patchRequest.email, + isDefaultImagePrivate = true, + defaultAlbumId = patchRequest.defaultAlbumId, + updateTime = now + ) + + every { userDao.findUserById(userId) } returns oldUserInfo + every { BCrypt.withDefaults().hashToString(12, patchRequest.password!!.toCharArray()) } returns encodedPassword + every { userDao.updateSelfById(exceptedUpdateDTO) } returns 1 + + userService.patchSelf(userId, patchRequest) + + verify(exactly = 1) { userDao.updateSelfById(exceptedUpdateDTO) } + } + + @Test + fun `patchSelf should throw UserNotFoundException if user does not exist`(): Unit = runBlocking { + val userId = 1L + val patchRequest = UserSelfPatchRequest() + + every { userDao.findUserById(userId) } returns null + + assertFailsWith { + userService.patchSelf(userId, patchRequest) + } + } + + @Test + fun `patchSelf should throw UserUpdateFailedException if user does not exist`(): Unit = runBlocking { + val userId = 1L + val patchRequest = UserSelfPatchRequest() + val oldUser = User( + id = userId, + name = "oldUser", + password = "oldPassword", + email = "old@example.com", + isDefaultImagePrivate = false, + defaultAlbumId = 1, + groupId = 1, + isBanned = false, + updateTime = now, + createTime = now + ) + + every { userDao.findUserById(userId) } returns oldUser + every { userDao.updateSelfById(any()) } returns 0 + + assertFailsWith { + userService.patchSelf(userId, patchRequest) + } + } + + @Test + fun `patchSelf should retain old password if new password is not provided`() = runBlocking { + val userId = 1L + val oldPassword = "oldPassword" + val oldUserInfo = User( + id = userId, + name = "oldUser", + password = oldPassword, + email = "old@example.com", + isDefaultImagePrivate = false, + defaultAlbumId = 1, + groupId = 1, + isBanned = false, + updateTime = now, + createTime = now + ) + val patchRequest = UserSelfPatchRequest( + email = "new@example.com" + ) + + every { userDao.findUserById(userId) } returns oldUserInfo + every { userDao.updateSelfById(any()) } returns 1 + + userService.patchSelf(userId, patchRequest) + + verify(exactly = 1) { + userDao.updateSelfById(match { it.password == oldPassword }) + } + } + + @Test + fun `patchUser should update user successfully with all new data`() = runBlocking { + val userId = 1L + val oldUserInfo = User( + id = userId, + name = "oldUser", + password = "oldPassword", + email = "old@example.com", + isDefaultImagePrivate = false, + defaultAlbumId = 1, + groupId = 1, + isBanned = false, + updateTime = now, + createTime = now + ) + val patchRequest = UserManagePatchRequest( + groupId = 2, + password = "newPassword", + email = "new@example.com", + isDefaultImagePrivate = true, + defaultAlbumId = 2 + ) + val encodedPassword = "encodedNewPassword" + + val exceptedUpdateDTO = UserManageUpdateDTO( + id = userId, + groupId = 2, + password = encodedPassword, + email = patchRequest.email, + isDefaultImagePrivate = true, + defaultAlbumId = patchRequest.defaultAlbumId, + updateTime = now + ) + + every { userDao.findUserById(userId) } returns oldUserInfo + every { BCrypt.withDefaults().hashToString(12, patchRequest.password!!.toCharArray()) } returns encodedPassword + every { userDao.updateUserById(exceptedUpdateDTO) } returns 1 + every { imageDao.updateImageGroupIdByUserId(userId, 2) } returns 1 + + userService.patchUser(userId, patchRequest) + + verify(exactly = 1) { userDao.updateUserById(exceptedUpdateDTO) } + coVerify(exactly = 1) { imageDao.updateImageGroupIdByUserId(userId, 2) } + } + + @Test + fun `patchUser should throw UserNotFoundException if user does not exist`(): Unit = runBlocking { + val userId = 1L + val patchRequest = UserManagePatchRequest() + + every { userDao.findUserById(userId) } returns null + + assertFailsWith { + userService.patchUser(userId, patchRequest) + } + } + + @Test + fun `patchUser should throw UserUpdateFailedException if user does not exist`(): Unit = runBlocking { + val userId = 1L + val oldUserInfo = User( + id = userId, + name = "oldUser", + password = "oldPassword", + email = "old@example.com", + isDefaultImagePrivate = false, + defaultAlbumId = 1, + groupId = 1, + isBanned = false, + updateTime = now, + createTime = now + ) + val patchRequest = UserManagePatchRequest() + + every { userDao.findUserById(userId) } returns oldUserInfo + every { userDao.updateUserById(any()) } returns 0 + + assertFailsWith { + userService.patchUser(userId, patchRequest) + } + } + + @Test + fun `patchUser should retain old password if new password is not provided`() = runBlocking { + val userId = 1L + val oldPassword = "oldPassword" + val oldUserInfo = User( + id = userId, + name = "oldUser", + password = oldPassword, + email = "old@example.com", + isDefaultImagePrivate = false, + defaultAlbumId = 1, + groupId = 1, + isBanned = false, + updateTime = now, + createTime = now + ) + val patchRequest = UserManagePatchRequest( + email = "new@example.com" + ) + + every { userDao.findUserById(userId) } returns oldUserInfo + every { userDao.updateUserById(any()) } returns 1 + + userService.patchUser(userId, patchRequest) + + verify(exactly = 1) { + userDao.updateUserById(match { it.password == oldPassword }) + } + } + + @Test + fun `banUser should update ban status to true when user exists`() = runBlocking { + val userId = 1L + + every { userDao.updateUserBanStatusById(userId, true) } returns 1 + + userService.banUser(userId) + + verify(exactly = 1) { userDao.updateUserBanStatusById(userId, true) } + } + + @Test + fun `banUser should throw UserUpdateFailedException when user does not exist`(): Unit = runBlocking { + val userId = 1L + + every { userDao.updateUserBanStatusById(userId, true) } returns 0 + + assertFailsWith { + userService.banUser(userId) + } + } + + @Test + fun `unbanUser should update ban status to false when user exists`() = runBlocking { + val userId = 1L + + every { userDao.updateUserBanStatusById(userId, false) } returns 1 + + userService.unbanUser(userId) + + verify(exactly = 1) { userDao.updateUserBanStatusById(userId, false) } + } + + @Test + fun `unbanUser should throw UserUpdateFailedException when user does not exist`(): Unit = runBlocking { + val userId = 1L + + every { userDao.updateUserBanStatusById(userId, false) } returns 0 + + assertFailsWith { + userService.unbanUser(userId) + } + } + + @Test + fun `fetchUser should return valid UserVO when user exists and all related data is available`() = runBlocking { + val userId = 1L + val user = User( + id = userId, + name = "oldUser", + password = "oldPassword", + email = "old@example.com", + isDefaultImagePrivate = false, + defaultAlbumId = 1, + groupId = 1, + isBanned = false, + updateTime = now, + createTime = now + ) + val group = Group( + id = 2, + name = "UserGroup", + description = null, + strategyId = 1 + ) + val imageCountAndTotalSizeDTO = ImageCountAndTotalSizeDTO(10, 2048.0) + + val exceptedUserVO = UserVO( + id = userId, + username = "oldUser", + groupName = "UserGroup", + email = "old@example.com", + isDefaultImagePrivate = false, + isBanned = false, + createTime = now, + imageCount = imageCountAndTotalSizeDTO.count, + totalImageSize = imageCountAndTotalSizeDTO.totalSize, + ) + + coEvery { DatabaseSingleton.dbQuery(any()) } coAnswers { + this.arg UserVO>(0).invoke() + } + every { userDao.findUserById(userId) } returns user + every { groupDao.findGroupById(user.groupId) } returns group + every { imageDao.getImageCountAndTotalSizeOfUser(userId) } returns imageCountAndTotalSizeDTO + + val result = userService.fetchUser(userId) + + assertEquals(exceptedUserVO, result) + } + + @Test + fun `fetchUser should throw UserNotFoundException when user does not exist`(): Unit = runBlocking { + val userId = 1L + + coEvery { userDao.findUserById(userId) } returns null + + assertFailsWith { + userService.fetchUser(userId) + } + } } \ No newline at end of file