diff --git a/gradle.properties b/gradle.properties index 028b076..c3052f8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,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=false +USE_LOCAL_MOCKS=true diff --git a/src/main/kotlin/chatserver/ChatServer.kt b/src/main/kotlin/chatserver/ChatServer.kt index 4280b03..78afe87 100644 --- a/src/main/kotlin/chatserver/ChatServer.kt +++ b/src/main/kotlin/chatserver/ChatServer.kt @@ -2,10 +2,12 @@ package chatserver import chatserver.MessagesRepository.Read import chatserver.MessagesRepository.Write +import chatserver.profiles.chatServerProfilesModule import org.koin.dsl.binds import org.koin.dsl.module val chatServerModule = module { + includes(chatServerProfilesModule) factory { MockMessages } binds arrayOf(Read::class, Write::class) } diff --git a/src/main/kotlin/chatserver/profiles/ChatServerProfiles.kt b/src/main/kotlin/chatserver/profiles/ChatServerProfiles.kt new file mode 100644 index 0000000..311caee --- /dev/null +++ b/src/main/kotlin/chatserver/profiles/ChatServerProfiles.kt @@ -0,0 +1,31 @@ +package chatserver.profiles + +import arch.RokyDispatchers +import chatserver.ProfileResult +import chatserver.ReadChatRepository +import chatserver.SubscribeChatRepository +import chatserver.WriteChatRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import org.koin.dsl.module + +val chatServerProfilesModule = + module { + single { + LocalProfilesRepository( + get(), + CoroutineScope( + SupervisorJob() + get().default, + ), + ) + } + factory> { + get() + } + factory> { + get() + } + factory { + get() + } + } diff --git a/src/main/kotlin/chatserver/profiles/LocalProfilesRepository.kt b/src/main/kotlin/chatserver/profiles/LocalProfilesRepository.kt new file mode 100644 index 0000000..a977863 --- /dev/null +++ b/src/main/kotlin/chatserver/profiles/LocalProfilesRepository.kt @@ -0,0 +1,66 @@ +package chatserver.profiles + +import arch.RokyDispatchers +import chatroom.viewmessages.ViewMessagesUseCase +import chatserver.ProfileResult +import chatserver.ProfileResult.Companion.ok +import chatserver.ReadChatRepository +import chatserver.SubscribeChatRepository +import chatserver.WriteChatRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.seconds + +class LocalProfilesRepository( + private val dispatchers: RokyDispatchers, + private val scope: CoroutineScope, +) : ReadChatRepository, WriteChatRepository, SubscribeChatRepository { + private val state: MutableStateFlow = MutableStateFlow(ok(emptyMap())) + + override fun latest(): ProfileResult { + return state.value + } + + override fun observe(): Flow { + return state.asStateFlow() + } + + override fun subscribe() { + scope.launch(dispatchers.default) { + while (true) { + val users = ViewMessagesUseCase.sampleUsers.shuffled() + state.value = users.associateWith { it }.let(ProfileResult::ok) + delay(5.seconds) + } + } + } + + override fun unsubscribe() { + // deliberately empty + } + + override fun write(item: String) { + scope.launch { + try { + require(item.isNotBlank()) { "Username cannot be empty." } + delay(2.seconds) + check(item.isValidUsername()) { "Could not assign current username." } + val profiles = latest().item.toMutableMap() + profiles[item] = item + state.value = ok(profiles) + } catch (e: Exception) { + state.value = ProfileResult.fail(e) + } + } + } + + companion object { + private const val VALID_LENGTH = 6 + + private fun String.isValidUsername(): Boolean = length < VALID_LENGTH + } +} diff --git a/src/main/kotlin/profile/Profile.kt b/src/main/kotlin/profile/Profile.kt index 7f46cf5..577e637 100644 --- a/src/main/kotlin/profile/Profile.kt +++ b/src/main/kotlin/profile/Profile.kt @@ -9,10 +9,9 @@ val profileModules = ProfilePresenter( windowScope = get().windowScope, requestUsername = get(), + usernames = get(), dispatchers = get(), ) } - scoped { RequestUserNameUseCase(get()) } } - factory { RequestUserNameUseCase.RequestUserName() } } diff --git a/src/main/kotlin/profile/ProfilePresenter.kt b/src/main/kotlin/profile/ProfilePresenter.kt index d3e14fb..a4503f5 100644 --- a/src/main/kotlin/profile/ProfilePresenter.kt +++ b/src/main/kotlin/profile/ProfilePresenter.kt @@ -2,16 +2,22 @@ package profile import arch.Presenter import arch.RokyDispatchers +import chatserver.ProfileResult +import chatserver.ReadChatRepository +import chatserver.WriteChatRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import org.jetbrains.annotations.VisibleForTesting import profile.ProfileEvent.RequestUsername -import profile.ProfileViewState.Idle -import profile.ProfileViewState.Pending +import profile.ProfileViewState.* class ProfilePresenter( private val windowScope: CoroutineScope, - private val requestUsername: RequestUserNameUseCase, + private val requestUsername: WriteChatRepository, + private val usernames: ReadChatRepository, dispatchers: RokyDispatchers, ) : Presenter(dispatchers) { private val state: MutableStateFlow = MutableStateFlow(Idle) @@ -20,6 +26,9 @@ class ProfilePresenter( windowScope.launch(dispatchers.main) { state.collect(::show) } + windowScope.launch(dispatchers.io) { + usernames.observe().drop(1).map { it.toViewState() }.collect { state.value = it } + } } override fun onDetach(view: ProfileView) { @@ -35,11 +44,23 @@ class ProfilePresenter( private fun onRequestUsername(event: RequestUsername) { state.value = Pending windowScope.launch(dispatchers.io) { - state.value = requestUsername(event.username) + requestUsername.write(event.username) } } private fun show(viewState: ProfileViewState) { withView { view -> view.show(viewState) } } + + private fun ProfileResult.toViewState(): ProfileViewState = + if (isOk) { + Success(MESSAGE_OK) + } else { + Failed(error?.message ?: "Unsuccessful!") + } + + companion object { + @VisibleForTesting + internal const val MESSAGE_OK = "Successfully changed username!" + } } diff --git a/src/main/kotlin/profile/RequestUserNameUseCase.kt b/src/main/kotlin/profile/RequestUserNameUseCase.kt deleted file mode 100644 index 9866a17..0000000 --- a/src/main/kotlin/profile/RequestUserNameUseCase.kt +++ /dev/null @@ -1,38 +0,0 @@ -package profile - -import kotlinx.coroutines.delay -import profile.ProfileViewState.Failed -import profile.ProfileViewState.Success -import kotlin.time.Duration.Companion.seconds - -class RequestUserNameUseCase( - private val request: RequestUserName, -) { - suspend operator fun invoke(username: String): ProfileViewState { - if (username.isBlank()) { - return Failed(ERROR_USERNAME_BLANK) - } - return with(username) { - if (request(this)) toSuccess() else toFailed() - } - } - - companion object { - const val ERROR_USERNAME_BLANK = "Username cannot be blank." - - private fun String.toSuccess(): ProfileViewState = Success("Changed username to $this") - - private fun String.toFailed(): ProfileViewState = Failed("Could not change username to\n$this") - } - - class RequestUserName { - suspend operator fun invoke(username: String): Boolean { - delay(2.seconds) - return username.length < VALID_LENGTH - } - - companion object { - private const val VALID_LENGTH = 6 - } - } -} diff --git a/src/test/kotlin/profile/ProfilePresenterTest.kt b/src/test/kotlin/profile/ProfilePresenterTest.kt index 0b4892c..4c33f01 100644 --- a/src/test/kotlin/profile/ProfilePresenterTest.kt +++ b/src/test/kotlin/profile/ProfilePresenterTest.kt @@ -1,44 +1,65 @@ package profile import arch.RokyDispatchers -import coAnswersDelayed -import io.mockk.* +import chatserver.ProfileResult +import chatserver.ReadChatRepository +import chatserver.WriteChatRepository +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import io.mockk.verifyOrder import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import profile.ProfilePresenter.Companion.MESSAGE_OK import profile.ProfileViewState.* @OptIn(ExperimentalCoroutinesApi::class) class ProfilePresenterTest { private lateinit var scope: CoroutineScope private lateinit var view: ProfileView - private lateinit var requestUsername: RequestUserNameUseCase + private lateinit var usernames: MutableStateFlow private lateinit var presenter: ProfilePresenter @BeforeEach fun setUp() { - requestUsername = mockk() - coEvery { requestUsername(any()) } coAnswersDelayed { Idle } - view = mockk(relaxed = true) val dispatchers: RokyDispatchers = mockk().apply { every { main } returns dispatcher every { io } returns dispatcher } + usernames = MutableStateFlow(ProfileResult.ok(emptyMap())) + val usernameRepository: ReadChatRepository = mockk() + every { usernameRepository.observe() } returns usernames + every { usernameRepository.latest() } answers { + usernames.value + } + val writeUsername: WriteChatRepository = mockk() + every { writeUsername.write(any()) } answers { + val user = it.invocation.args [0] as String + usernames.value = + if (user == INVALID_USER) { + ProfileResult.fail(RuntimeException()) + } else { + val currentUsernames = usernames.value.item.toMutableMap() + currentUsernames[user] = user + ProfileResult.ok(currentUsernames) + } + } scope = CoroutineScope(dispatcher) - presenter = ProfilePresenter(scope, requestUsername, dispatchers) + presenter = ProfilePresenter(scope, writeUsername, usernameRepository, dispatchers) } @Test fun `given user requests new usernames, when request is successful, then show success status`() = runTest(dispatcher) { - coEvery { requestUsername(any()) } coAnswersDelayed { Success("OKAYYY") } presenter.attach(view) advanceUntilIdle() presenter.onEvent(ProfileEvent.RequestUsername("USERNAMEE")) @@ -49,7 +70,7 @@ class ProfilePresenterTest { view.show( withArg { assertTrue(it is Success) - assertTrue(it.status == "OKAYYY") + assertTrue(it.status == MESSAGE_OK) }, ) } @@ -58,10 +79,9 @@ class ProfilePresenterTest { @Test fun `given user requests new username, when request fails, then show failed status`() = runTest(dispatcher) { - coEvery { requestUsername(any()) } coAnswersDelayed { Failed("!OKAYYY") } presenter.attach(view) advanceUntilIdle() - presenter.onEvent(ProfileEvent.RequestUsername("USERNAMEE")) + presenter.onEvent(ProfileEvent.RequestUsername(INVALID_USER)) advanceUntilIdle() verifyOrder { view.show(Idle) @@ -69,7 +89,6 @@ class ProfilePresenterTest { view.show( withArg { assertTrue(it is Failed) - assertTrue(it.status == "!OKAYYY") }, ) } @@ -85,5 +104,6 @@ class ProfilePresenterTest { companion object { private val dispatcher = StandardTestDispatcher() + private const val INVALID_USER = "INVALID USER!" } } diff --git a/src/test/kotlin/profile/RequestUserNameUseCaseTest.kt b/src/test/kotlin/profile/RequestUserNameUseCaseTest.kt deleted file mode 100644 index df97521..0000000 --- a/src/test/kotlin/profile/RequestUserNameUseCaseTest.kt +++ /dev/null @@ -1,70 +0,0 @@ -package profile - -import coAnswersDelayed -import io.kotest.matchers.equals.shouldBeEqual -import io.kotest.matchers.string.shouldStartWith -import io.mockk.coEvery -import io.mockk.mockk -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import profile.ProfileViewState.Failed -import profile.ProfileViewState.Success -import profile.RequestUserNameUseCase.Companion.ERROR_USERNAME_BLANK -import profile.RequestUserNameUseCase.RequestUserName - -class RequestUserNameUseCaseTest { - private lateinit var requestUserName: RequestUserName - private lateinit var useCase: RequestUserNameUseCase - - @BeforeEach - fun setUp() { - requestUserName = mockk() - coEvery { requestUserName(any()) } coAnswersDelayed { - firstArg() == VALID_USER - } - useCase = RequestUserNameUseCase(requestUserName) - } - - @Test - fun `when username is empty, then request username failed`() = - runTest { - assertTrue(useCase("") is Failed) - } - - @Test - fun `when username is empty, then show username cannot be blank message`() = - runTest { - useCase("").status shouldBeEqual ERROR_USERNAME_BLANK - } - - @Test - fun `when username is invalid, then request username failed`() = - runTest { - assertTrue(useCase(INVALID_USER) is Failed) - } - - @Test - fun `when username is invalid, then show username invalid message`() = - runTest { - useCase(INVALID_USER).status shouldStartWith "Could not change username" - } - - @Test - fun `when username is valid, then request username success`() = - runTest { - assertTrue(useCase(VALID_USER) is Success) - } - - @Test - fun `when username is valid, then show username changed message`() = - runTest { - useCase(VALID_USER).status shouldStartWith "Changed username to" - } - - companion object { - private const val VALID_USER = "valid_user" - private const val INVALID_USER = "invalid_user" - } -}