From 8f6bfac555974fadd55a402404f4c226fe712e49 Mon Sep 17 00:00:00 2001 From: KaiRaiChu Date: Thu, 23 Jul 2026 19:37:40 +0100 Subject: [PATCH 1/6] #195: Verify username not blank --- .../kotlin/chatserver/profiles/SupabaseProfilesRepository.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/chatserver/profiles/SupabaseProfilesRepository.kt b/src/main/kotlin/chatserver/profiles/SupabaseProfilesRepository.kt index fc20dc2..7e1decc 100644 --- a/src/main/kotlin/chatserver/profiles/SupabaseProfilesRepository.kt +++ b/src/main/kotlin/chatserver/profiles/SupabaseProfilesRepository.kt @@ -24,7 +24,7 @@ class SupabaseProfilesRepository( override fun observe(): Flow = profiles.asStateFlow() override fun write(requestedUsername: String) { - require(requestedUsername.isBlank()) { + require(requestedUsername.isNotBlank()) { "Requested username must not be blank." } val id = From a739919d49c163edf8641339873c67f79e1107b1 Mon Sep 17 00:00:00 2001 From: Tom Calver Date: Thu, 13 Aug 2026 19:09:10 +0100 Subject: [PATCH 2/6] #195: Add username validation usecase --- .../profiles/SupabaseProfilesRepository.kt | 8 +-- .../kotlin/profile/ValidateUsernameRequest.kt | 57 +++++++++++++++++++ 2 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 src/main/kotlin/profile/ValidateUsernameRequest.kt diff --git a/src/main/kotlin/chatserver/profiles/SupabaseProfilesRepository.kt b/src/main/kotlin/chatserver/profiles/SupabaseProfilesRepository.kt index 7e1decc..11f0679 100644 --- a/src/main/kotlin/chatserver/profiles/SupabaseProfilesRepository.kt +++ b/src/main/kotlin/chatserver/profiles/SupabaseProfilesRepository.kt @@ -23,8 +23,8 @@ class SupabaseProfilesRepository( override fun observe(): Flow = profiles.asStateFlow() - override fun write(requestedUsername: String) { - require(requestedUsername.isNotBlank()) { + override fun write(item: String) { + require(item.isNotBlank()) { "Requested username must not be blank." } val id = @@ -32,14 +32,14 @@ class SupabaseProfilesRepository( throw IllegalStateException("User ID must not be blank.") } val currentUsername = latest().item[id]?.username - check(currentUsername == requestedUsername) { + check(currentUsername == item) { "Current username must not match requested username." } scope.launch { try { client.from("profiles") .update({ - set("username", requestedUsername) + set("username", item) }) { filter { eq("id", id) diff --git a/src/main/kotlin/profile/ValidateUsernameRequest.kt b/src/main/kotlin/profile/ValidateUsernameRequest.kt new file mode 100644 index 0000000..49ce5b3 --- /dev/null +++ b/src/main/kotlin/profile/ValidateUsernameRequest.kt @@ -0,0 +1,57 @@ +package profile + +import chatserver.LoggedInUserId +import chatserver.ProfileResult +import chatserver.ReadChatRepository +import chatserver.WriteChatRepository +import profile.ValidateUsernameRequest.Result.* + +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() + if (profiles.item[loggedInUser]?.username == username) + return UsernameUnchanged + + write.write(username) + return with(read.latest()) { + if (isOk) Ok else ServerError(error!!) + } + } + + 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 class ServerError(val error: Exception) : Result { + override val message: String = error.localizedMessage + } + } +} From 6c0191b6fef0f8c2ed4d56c42d379a16bffe5bb4 Mon Sep 17 00:00:00 2001 From: KaiRaiChu Date: Thu, 13 Aug 2026 20:37:26 +0100 Subject: [PATCH 3/6] #195: Handled server timeout when requesting new username --- config/detekt/detekt.yml | 6 +-- .../kotlin/profile/ValidateUsernameRequest.kt | 39 ++++++++++----- .../profile/ValidateUsernameRequestTest.kt | 49 +++++++++++++++++++ 3 files changed, 80 insertions(+), 14 deletions(-) create mode 100644 src/test/kotlin/profile/ValidateUsernameRequestTest.kt 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/src/main/kotlin/profile/ValidateUsernameRequest.kt b/src/main/kotlin/profile/ValidateUsernameRequest.kt index 49ce5b3..66505d9 100644 --- a/src/main/kotlin/profile/ValidateUsernameRequest.kt +++ b/src/main/kotlin/profile/ValidateUsernameRequest.kt @@ -4,34 +4,51 @@ 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()) + if (username.isBlank()) { return UsernameBlank + } val loggedInUser = userId() - if (loggedInUser.isBlank()) + if (loggedInUser.isBlank()) { return LoggedInUsernameBlank + } val profiles = read.latest() - if (profiles.item[loggedInUser]?.username == username) + val me = profiles.item[loggedInUser] + if (me?.username == username) { return UsernameUnchanged - - write.write(username) - return with(read.latest()) { - if (isOk) Ok else ServerError(error!!) } + + return read.observe().waitForValue(Profile(loggedInUser, username)) } - sealed interface Result { + 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 { @@ -50,8 +67,8 @@ class ValidateUsernameRequest( override val message: String = "Current username must not match requested username." } - data class ServerError(val error: Exception) : Result { - override val message: String = error.localizedMessage + 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..defea98 --- /dev/null +++ b/src/test/kotlin/profile/ValidateUsernameRequestTest.kt @@ -0,0 +1,49 @@ +package profile + +import chatserver.LoggedInUserId +import chatserver.ProfileResult +import chatserver.ReadChatRepository +import chatserver.WriteChatRepository +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.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import profile.ValidateUsernameRequest.Result.UsernameBlank + +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 + } +} From 5ca388c6d882cbe9ad8a8c917a26956d42b33743 Mon Sep 17 00:00:00 2001 From: KaiRaiChu Date: Thu, 20 Aug 2026 19:57:03 +0100 Subject: [PATCH 4/6] #195: Add validate username requests tests --- build.gradle.kts | 2 +- .../profile/ValidateUsernameRequestTest.kt | 57 ++++++++++++++++++- 2 files changed, 57 insertions(+), 2 deletions(-) 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/src/test/kotlin/profile/ValidateUsernameRequestTest.kt b/src/test/kotlin/profile/ValidateUsernameRequestTest.kt index defea98..82bea28 100644 --- a/src/test/kotlin/profile/ValidateUsernameRequestTest.kt +++ b/src/test/kotlin/profile/ValidateUsernameRequestTest.kt @@ -4,17 +4,25 @@ 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.UsernameBlank +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 @@ -46,4 +54,51 @@ class ValidateUsernameRequestTest { 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() + } } From c0c4589a142d03b31959a190f01b8cfb711c5179 Mon Sep 17 00:00:00 2001 From: KaiRaiChu Date: Thu, 20 Aug 2026 20:05:46 +0100 Subject: [PATCH 5/6] #195: Up version to 0.1.5 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From c3176192e0c57f0a0968271115f26bd1685efde7 Mon Sep 17 00:00:00 2001 From: sorellla Date: Thu, 27 Aug 2026 20:26:39 +0200 Subject: [PATCH 6/6] #195: move logic out of repository class --- .../profiles/SupabaseProfilesRepository.kt | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/src/main/kotlin/chatserver/profiles/SupabaseProfilesRepository.kt b/src/main/kotlin/chatserver/profiles/SupabaseProfilesRepository.kt index 11f0679..76363a9 100644 --- a/src/main/kotlin/chatserver/profiles/SupabaseProfilesRepository.kt +++ b/src/main/kotlin/chatserver/profiles/SupabaseProfilesRepository.kt @@ -24,17 +24,6 @@ class SupabaseProfilesRepository( override fun observe(): Flow = profiles.asStateFlow() override fun write(item: String) { - require(item.isNotBlank()) { - "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 == item) { - "Current username must not match requested username." - } scope.launch { try { client.from("profiles") @@ -42,7 +31,7 @@ class SupabaseProfilesRepository( set("username", item) }) { filter { - eq("id", id) + eq("id", userId()) } } } catch (e: Exception) {