diff --git a/build.gradle.kts b/build.gradle.kts index 9eacafd..be3a381 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -59,7 +59,7 @@ dependencies { implementation("com.googlecode.lanterna:lanterna:3.1.1") implementation("com.vladsch.flexmark:flexmark-all:0.64.8") - implementation(project.dependencies.platform("io.insert-koin:koin-bom:3.5.6")) + implementation(platform("io.insert-koin:koin-bom:3.5.6")) implementation("io.insert-koin:koin-core") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0") diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index 0dfeff4..fc9fbba 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -674,13 +674,13 @@ style: RedundantVisibilityModifierRule: active: false ReturnCount: - active: true + active: false max: 2 excludedFunctions: - 'equals' - excludeLabeled: false + excludeLabeled: true excludeReturnFromLambda: true - excludeGuardClauses: false + excludeGuardClauses: true SafeCast: active: true SerialVersionUIDInSerializableClass: diff --git a/gradle.properties b/gradle.properties index 6eeede9..2096f4f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,4 +2,4 @@ kotlin.code.style=official # Determines whether this project should use hardcoded mocks or Supabase-CLI. # See https://github.com/PPartisan/Roky/wiki/Project-Setup USE_LOCAL_MOCKS=true -version=0.1.4 +version=0.1.5 diff --git a/src/main/kotlin/chatserver/profiles/SupabaseProfilesRepository.kt b/src/main/kotlin/chatserver/profiles/SupabaseProfilesRepository.kt index fc20dc2..76363a9 100644 --- a/src/main/kotlin/chatserver/profiles/SupabaseProfilesRepository.kt +++ b/src/main/kotlin/chatserver/profiles/SupabaseProfilesRepository.kt @@ -23,26 +23,15 @@ class SupabaseProfilesRepository( override fun observe(): Flow = profiles.asStateFlow() - override fun write(requestedUsername: String) { - require(requestedUsername.isBlank()) { - "Requested username must not be blank." - } - val id = - userId().ifBlank { - throw IllegalStateException("User ID must not be blank.") - } - val currentUsername = latest().item[id]?.username - check(currentUsername == requestedUsername) { - "Current username must not match requested username." - } + override fun write(item: String) { scope.launch { try { client.from("profiles") .update({ - set("username", requestedUsername) + set("username", item) }) { filter { - eq("id", id) + eq("id", userId()) } } } catch (e: Exception) { diff --git a/src/main/kotlin/profile/ValidateUsernameRequest.kt b/src/main/kotlin/profile/ValidateUsernameRequest.kt new file mode 100644 index 0000000..66505d9 --- /dev/null +++ b/src/main/kotlin/profile/ValidateUsernameRequest.kt @@ -0,0 +1,74 @@ +package profile + +import chatserver.LoggedInUserId +import chatserver.ProfileResult +import chatserver.ReadChatRepository +import chatserver.WriteChatRepository +import chatserver.profiles.SupabaseProfilesRepository.Profile +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull +import profile.ValidateUsernameRequest.Result.* +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +class ValidateUsernameRequest( + private val write: WriteChatRepository, + private val read: ReadChatRepository, + private val userId: LoggedInUserId, +) { + suspend operator fun invoke(username: String): Result { + if (username.isBlank()) { + return UsernameBlank + } + + val loggedInUser = userId() + if (loggedInUser.isBlank()) { + return LoggedInUsernameBlank + } + + val profiles = read.latest() + val me = profiles.item[loggedInUser] + if (me?.username == username) { + return UsernameUnchanged + } + + return read.observe().waitForValue(Profile(loggedInUser, username)) + } + + private suspend fun Flow.waitForValue( + target: Profile, + timeout: Duration = 4.seconds, + ): Result { + write.write(target.username) + val result = + withTimeoutOrNull(timeout) { + first { it.isOk && it.item[target.id] == target } + } + return if (result != null) Ok else Timeout + } + + sealed interface Result { + val message: String + + data object Ok : Result { + override val message = "" + } + + data object UsernameBlank : Result { + override val message: String = "Requested username must not be blank." + } + + data object LoggedInUsernameBlank : Result { + override val message = "User ID must not be blank." + } + + data object UsernameUnchanged : Result { + override val message: String = "Current username must not match requested username." + } + + data object Timeout : Result { + override val message: String = "Server timeout! Trying to change username" + } + } +} diff --git a/src/test/kotlin/profile/ValidateUsernameRequestTest.kt b/src/test/kotlin/profile/ValidateUsernameRequestTest.kt new file mode 100644 index 0000000..82bea28 --- /dev/null +++ b/src/test/kotlin/profile/ValidateUsernameRequestTest.kt @@ -0,0 +1,104 @@ +package profile + +import chatserver.LoggedInUserId +import chatserver.ProfileResult +import chatserver.ReadChatRepository +import chatserver.WriteChatRepository +import chatserver.profiles.SupabaseProfilesRepository.Profile +import io.kotest.matchers.equals.shouldBeEqual +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import profile.ValidateUsernameRequest.Result.* +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +class ValidateUsernameRequestTest { + private lateinit var write: WriteChatRepository + private lateinit var read: ReadChatRepository + private lateinit var userId: LoggedInUserId + + private lateinit var request: ValidateUsernameRequest + + @BeforeEach + fun setUp() { + write = + mockk>().also { + every { it.write(any()) } just runs + } + read = + mockk>().also { + every { it.latest() } returns ProfileResult.ok(emptyMap()) + every { it.observe() } returns flowOf() + } + userId = + mockk().also { + every { it() } returns "Logged in user" + } + + request = ValidateUsernameRequest(write, read, userId) + } + + @Test + fun `when username blank, then return UsernameBlank`() = + runTest { + request("") shouldBeEqual UsernameBlank + } + + @Test + fun `when logged in username is blank, then return LoggedInUsernameBlank`() = + runTest { + every { userId() } returns "" + request("Kai") shouldBeEqual LoggedInUsernameBlank + } + + @Test + fun `when user requests username that matches their current username, then return UsernameUnchanged`() = + runTest { + every { userId() } returns "ka1" + every { read.latest() } returns ProfileResult.ok(mapOf("ka1" to Profile(id = "ka1", username = "kai"))) + request("kai") shouldBeEqual UsernameUnchanged + } + + @Test + fun `when requesting valid username, and server does not respond, then return Timeout`() = + runTest { + every { userId() } returns "ka1" + every { read.latest() } returns ProfileResult.ok(mapOf("ka1" to Profile(id = "ka1", username = "tom"))) + every { read.observe() } returns flow { (delay(5.seconds)) } + val job = + backgroundScope.launch { + request("kai") shouldBeEqual Timeout + } + advanceTimeBy(5.seconds) + job.cancel() + } + + @Test + fun `when requesting valid username, and server accepts, then return Ok`() = + runTest { + every { userId() } returns "ka1" + every { read.latest() } returns ProfileResult.ok(mapOf("ka1" to Profile(id = "ka1", username = "tom"))) + every { read.observe() } returns + flow { + delay(1.seconds) + emit(ProfileResult.ok(mapOf("ka1" to Profile(id = "ka1", username = "kai")))) + } + val job = + backgroundScope.launch { + request("kai") shouldBeEqual Ok + } + advanceTimeBy(5.seconds) + job.cancel() + } +}