Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
6 changes: 3 additions & 3 deletions config/detekt/detekt.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
17 changes: 3 additions & 14 deletions src/main/kotlin/chatserver/profiles/SupabaseProfilesRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,26 +23,15 @@ class SupabaseProfilesRepository(

override fun observe(): Flow<ProfileResult> = profiles.asStateFlow()

override fun write(requestedUsername: String) {
Comment thread
PPartisan marked this conversation as resolved.
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) {
Expand Down
74 changes: 74 additions & 0 deletions src/main/kotlin/profile/ValidateUsernameRequest.kt
Original file line number Diff line number Diff line change
@@ -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<String>,
private val read: ReadChatRepository<ProfileResult>,
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<ProfileResult>.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"
}
}
}
104 changes: 104 additions & 0 deletions src/test/kotlin/profile/ValidateUsernameRequestTest.kt
Original file line number Diff line number Diff line change
@@ -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<String>
private lateinit var read: ReadChatRepository<ProfileResult>
private lateinit var userId: LoggedInUserId

private lateinit var request: ValidateUsernameRequest

@BeforeEach
fun setUp() {
write =
mockk<WriteChatRepository<String>>().also {
every { it.write(any()) } just runs
}
read =
mockk<ReadChatRepository<ProfileResult>>().also {
every { it.latest() } returns ProfileResult.ok(emptyMap())
every { it.observe() } returns flowOf()
}
userId =
mockk<LoggedInUserId>().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()
}
}
Loading