feat: add boards drawer profile and workspace state flow
This commit is contained in:
@@ -19,6 +19,7 @@ import space.hackenslacker.kanbn4droid.app.boarddetail.CreatedEntityRef
|
||||
import space.hackenslacker.kanbn4droid.app.boards.BoardSummary
|
||||
import space.hackenslacker.kanbn4droid.app.boards.BoardTemplate
|
||||
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
|
||||
import space.hackenslacker.kanbn4droid.app.boards.DrawerProfile
|
||||
import space.hackenslacker.kanbn4droid.app.boards.WorkspaceSummary
|
||||
import space.hackenslacker.kanbn4droid.app.carddetail.CardActivity
|
||||
import space.hackenslacker.kanbn4droid.app.carddetail.CardDetail
|
||||
@@ -27,6 +28,10 @@ import space.hackenslacker.kanbn4droid.app.carddetail.CardDetailTag
|
||||
interface KanbnApiClient {
|
||||
suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult
|
||||
|
||||
suspend fun getCurrentUser(baseUrl: String, apiKey: String): BoardsApiResult<DrawerProfile> {
|
||||
return BoardsApiResult.Failure("Current user endpoint is not implemented.")
|
||||
}
|
||||
|
||||
suspend fun listWorkspaces(baseUrl: String, apiKey: String): BoardsApiResult<List<WorkspaceSummary>> {
|
||||
return BoardsApiResult.Failure("Workspace listing is not implemented.")
|
||||
}
|
||||
@@ -134,6 +139,25 @@ data class LabelDetail(
|
||||
|
||||
class HttpKanbnApiClient : KanbnApiClient {
|
||||
|
||||
override suspend fun getCurrentUser(baseUrl: String, apiKey: String): BoardsApiResult<DrawerProfile> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
request(
|
||||
baseUrl = baseUrl,
|
||||
path = "/api/v1/users/me",
|
||||
method = "GET",
|
||||
apiKey = apiKey,
|
||||
) { code, body ->
|
||||
if (code in 200..299) {
|
||||
parseUsersMeProfile(body)
|
||||
?.let { BoardsApiResult.Success(it) }
|
||||
?: BoardsApiResult.Failure("Malformed users/me response.")
|
||||
} else {
|
||||
BoardsApiResult.Failure(serverMessage(body, code))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun listWorkspaces(baseUrl: String, apiKey: String): BoardsApiResult<List<WorkspaceSummary>> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
request(
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
package space.hackenslacker.kanbn4droid.app.auth
|
||||
|
||||
import space.hackenslacker.kanbn4droid.app.boards.DrawerProfile
|
||||
|
||||
internal fun parseUsersMeProfile(body: String): DrawerProfile? {
|
||||
if (body.isBlank()) {
|
||||
return null
|
||||
}
|
||||
|
||||
val root = parseUsersMeJsonObject(body) ?: return null
|
||||
val data = root["data"] as? Map<*, *>
|
||||
val user = (data?.get("user") as? Map<*, *>)
|
||||
?: (root["user"] as? Map<*, *>)
|
||||
?: data
|
||||
?: root
|
||||
|
||||
val displayName = extractUsersMeString(user, "displayName", "username", "name", "email")
|
||||
if (displayName.isBlank()) {
|
||||
return null
|
||||
}
|
||||
val email = extractUsersMeString(user, "email").ifBlank { null }
|
||||
return DrawerProfile(displayName = displayName, email = email)
|
||||
}
|
||||
|
||||
private fun extractUsersMeString(source: Map<*, *>, vararg keys: String): String {
|
||||
return keys.firstNotNullOfOrNull { key -> source[key]?.toString()?.trim()?.takeIf { it.isNotEmpty() } }.orEmpty()
|
||||
}
|
||||
|
||||
private fun parseUsersMeJsonObject(body: String): Map<String, Any?>? {
|
||||
val parsed = parseUsersMeJsonValue(body)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return parsed as? Map<String, Any?>
|
||||
}
|
||||
|
||||
private fun parseUsersMeJsonValue(body: String): Any? {
|
||||
val trimmed = body.trim()
|
||||
if (trimmed.isBlank()) {
|
||||
return null
|
||||
}
|
||||
return runCatching { UsersMeMiniJsonParser(trimmed).parseValue() }.getOrNull()
|
||||
}
|
||||
|
||||
private class UsersMeMiniJsonParser(private val input: String) {
|
||||
private var index = 0
|
||||
|
||||
fun parseValue(): Any? {
|
||||
skipWhitespace()
|
||||
if (index >= input.length) {
|
||||
return null
|
||||
}
|
||||
return when (val ch = input[index]) {
|
||||
'{' -> parseObject()
|
||||
'[' -> parseArray()
|
||||
'"' -> parseString()
|
||||
't' -> parseLiteral("true", true)
|
||||
'f' -> parseLiteral("false", false)
|
||||
'n' -> parseLiteral("null", null)
|
||||
'-', in '0'..'9' -> parseNumber()
|
||||
else -> throw IllegalArgumentException("Unexpected token $ch at index $index")
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseObject(): Map<String, Any?> {
|
||||
expect('{')
|
||||
skipWhitespace()
|
||||
val result = linkedMapOf<String, Any?>()
|
||||
if (peek() == '}') {
|
||||
index += 1
|
||||
return result
|
||||
}
|
||||
while (index < input.length) {
|
||||
val key = parseString()
|
||||
skipWhitespace()
|
||||
expect(':')
|
||||
val value = parseValue()
|
||||
result[key] = value
|
||||
skipWhitespace()
|
||||
when (peek()) {
|
||||
',' -> index += 1
|
||||
'}' -> {
|
||||
index += 1
|
||||
return result
|
||||
}
|
||||
|
||||
else -> throw IllegalArgumentException("Expected , or } at index $index")
|
||||
}
|
||||
skipWhitespace()
|
||||
}
|
||||
throw IllegalArgumentException("Unclosed object")
|
||||
}
|
||||
|
||||
private fun parseArray(): List<Any?> {
|
||||
expect('[')
|
||||
skipWhitespace()
|
||||
val result = mutableListOf<Any?>()
|
||||
if (peek() == ']') {
|
||||
index += 1
|
||||
return result
|
||||
}
|
||||
while (index < input.length) {
|
||||
result += parseValue()
|
||||
skipWhitespace()
|
||||
when (peek()) {
|
||||
',' -> index += 1
|
||||
']' -> {
|
||||
index += 1
|
||||
return result
|
||||
}
|
||||
|
||||
else -> throw IllegalArgumentException("Expected , or ] at index $index")
|
||||
}
|
||||
skipWhitespace()
|
||||
}
|
||||
throw IllegalArgumentException("Unclosed array")
|
||||
}
|
||||
|
||||
private fun parseString(): String {
|
||||
expect('"')
|
||||
val result = StringBuilder()
|
||||
while (index < input.length) {
|
||||
val ch = input[index++]
|
||||
when (ch) {
|
||||
'"' -> return result.toString()
|
||||
'\\' -> {
|
||||
val escaped = input.getOrNull(index++) ?: throw IllegalArgumentException("Invalid escape")
|
||||
when (escaped) {
|
||||
'"' -> result.append('"')
|
||||
'\\' -> result.append('\\')
|
||||
'/' -> result.append('/')
|
||||
'b' -> result.append('\b')
|
||||
'f' -> result.append('\u000C')
|
||||
'n' -> result.append('\n')
|
||||
'r' -> result.append('\r')
|
||||
't' -> result.append('\t')
|
||||
'u' -> {
|
||||
val hex = input.substring(index, index + 4)
|
||||
index += 4
|
||||
result.append(hex.toInt(16).toChar())
|
||||
}
|
||||
|
||||
else -> throw IllegalArgumentException("Invalid escape token")
|
||||
}
|
||||
}
|
||||
|
||||
else -> result.append(ch)
|
||||
}
|
||||
}
|
||||
throw IllegalArgumentException("Unclosed string")
|
||||
}
|
||||
|
||||
private fun parseNumber(): Any {
|
||||
val start = index
|
||||
if (peek() == '-') {
|
||||
index += 1
|
||||
}
|
||||
while (peek()?.isDigit() == true) {
|
||||
index += 1
|
||||
}
|
||||
var isFloating = false
|
||||
if (peek() == '.') {
|
||||
isFloating = true
|
||||
index += 1
|
||||
while (peek()?.isDigit() == true) {
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
if (peek() == 'e' || peek() == 'E') {
|
||||
isFloating = true
|
||||
index += 1
|
||||
if (peek() == '+' || peek() == '-') {
|
||||
index += 1
|
||||
}
|
||||
while (peek()?.isDigit() == true) {
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
val token = input.substring(start, index)
|
||||
return if (isFloating) token.toDouble() else token.toLong()
|
||||
}
|
||||
|
||||
private fun parseLiteral(token: String, value: Any?): Any? {
|
||||
if (!input.startsWith(token, index)) {
|
||||
throw IllegalArgumentException("Expected $token at index $index")
|
||||
}
|
||||
index += token.length
|
||||
return value
|
||||
}
|
||||
|
||||
private fun expect(expected: Char) {
|
||||
skipWhitespace()
|
||||
if (peek() != expected) {
|
||||
throw IllegalArgumentException("Expected $expected at index $index")
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
|
||||
private fun peek(): Char? = input.getOrNull(index)
|
||||
|
||||
private fun skipWhitespace() {
|
||||
while (peek()?.isWhitespace() == true) {
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package space.hackenslacker.kanbn4droid.app.boards
|
||||
|
||||
enum class DrawerDataErrorCode {
|
||||
NONE,
|
||||
UNAUTHORIZED,
|
||||
NETWORK,
|
||||
SERVER,
|
||||
}
|
||||
|
||||
data class DrawerProfile(
|
||||
val displayName: String,
|
||||
val email: String?,
|
||||
)
|
||||
|
||||
data class DrawerDataResult(
|
||||
val profile: DrawerProfile?,
|
||||
val workspaces: List<WorkspaceSummary>,
|
||||
val activeWorkspaceId: String?,
|
||||
val profileError: String?,
|
||||
val workspacesError: String?,
|
||||
val errorCode: DrawerDataErrorCode,
|
||||
val didFallbackWorkspace: Boolean = false,
|
||||
)
|
||||
|
||||
data class BoardsDrawerState(
|
||||
val isLoading: Boolean = false,
|
||||
val profile: DrawerProfile? = null,
|
||||
val workspaces: List<WorkspaceSummary> = emptyList(),
|
||||
val activeWorkspaceId: String? = null,
|
||||
val profileError: String? = null,
|
||||
val workspacesError: String? = null,
|
||||
val errorCode: DrawerDataErrorCode = DrawerDataErrorCode.NONE,
|
||||
val isRetryable: Boolean = false,
|
||||
val isWorkspaceInteractionEnabled: Boolean = false,
|
||||
val isWorkspaceSwitchInFlight: Boolean = false,
|
||||
)
|
||||
@@ -13,6 +13,69 @@ class BoardsRepository(
|
||||
private val apiClient: KanbnApiClient,
|
||||
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
suspend fun loadDrawerData(): DrawerDataResult {
|
||||
val session = when (val sessionResult = authSession()) {
|
||||
is BoardsApiResult.Success -> sessionResult.value
|
||||
is BoardsApiResult.Failure -> {
|
||||
return DrawerDataResult(
|
||||
profile = null,
|
||||
workspaces = emptyList(),
|
||||
activeWorkspaceId = null,
|
||||
profileError = sessionResult.message,
|
||||
workspacesError = sessionResult.message,
|
||||
errorCode = DrawerDataErrorCode.SERVER,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val profileResult = apiClient.getCurrentUser(session.baseUrl, session.apiKey)
|
||||
val workspacesResult = apiClient.listWorkspaces(session.baseUrl, session.apiKey)
|
||||
|
||||
val profile = (profileResult as? BoardsApiResult.Success)?.value
|
||||
val profileError = (profileResult as? BoardsApiResult.Failure)?.message
|
||||
val workspaces = (workspacesResult as? BoardsApiResult.Success)?.value.orEmpty()
|
||||
val workspacesError = (workspacesResult as? BoardsApiResult.Failure)?.message
|
||||
|
||||
val workspaceResolution = resolveActiveWorkspaceForDrawer(
|
||||
workspaces = workspaces,
|
||||
workspacesError = workspacesError,
|
||||
)
|
||||
val activeWorkspaceId = workspaceResolution.activeWorkspaceId
|
||||
val errorCode = deriveDrawerErrorCode(profileError = profileError, workspacesError = workspacesError)
|
||||
|
||||
return DrawerDataResult(
|
||||
profile = profile,
|
||||
workspaces = workspaces,
|
||||
activeWorkspaceId = activeWorkspaceId,
|
||||
profileError = profileError,
|
||||
workspacesError = workspacesError,
|
||||
errorCode = errorCode,
|
||||
didFallbackWorkspace = workspaceResolution.didFallback,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun switchWorkspace(workspaceId: String): BoardsApiResult<Unit> {
|
||||
val normalizedWorkspaceId = workspaceId.trim()
|
||||
if (normalizedWorkspaceId.isBlank()) {
|
||||
return BoardsApiResult.Failure("Workspace id is required")
|
||||
}
|
||||
val session = when (val sessionResult = authSession()) {
|
||||
is BoardsApiResult.Success -> sessionResult.value
|
||||
is BoardsApiResult.Failure -> return sessionResult
|
||||
}
|
||||
|
||||
sessionStore.saveWorkspaceId(normalizedWorkspaceId)
|
||||
val listBoardsResult = apiClient.listBoards(
|
||||
baseUrl = session.baseUrl,
|
||||
apiKey = session.apiKey,
|
||||
workspaceId = normalizedWorkspaceId,
|
||||
)
|
||||
return when (listBoardsResult) {
|
||||
is BoardsApiResult.Success -> BoardsApiResult.Success(Unit)
|
||||
is BoardsApiResult.Failure -> listBoardsResult
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun listBoards(): BoardsApiResult<List<BoardSummary>> {
|
||||
val session = when (val sessionResult = session()) {
|
||||
is BoardsApiResult.Success -> sessionResult.value
|
||||
@@ -66,23 +129,31 @@ class BoardsRepository(
|
||||
}
|
||||
|
||||
private suspend fun session(): BoardsApiResult<SessionSnapshot> {
|
||||
val baseUrl = sessionStore.getBaseUrl()?.takeIf { it.isNotBlank() }
|
||||
?: return BoardsApiResult.Failure("Missing session. Please sign in again.")
|
||||
val apiKey = withContext(ioDispatcher) {
|
||||
apiKeyStore.getApiKey(baseUrl)
|
||||
}.getOrNull()?.takeIf { it.isNotBlank() }
|
||||
?: return BoardsApiResult.Failure("Missing session. Please sign in again.")
|
||||
val authSession = when (val sessionResult = authSession()) {
|
||||
is BoardsApiResult.Success -> sessionResult.value
|
||||
is BoardsApiResult.Failure -> return sessionResult
|
||||
}
|
||||
|
||||
val workspaceId = when (val workspaceResult = resolveWorkspaceId(baseUrl = baseUrl, apiKey = apiKey)) {
|
||||
val workspaceId = when (val workspaceResult = resolveWorkspaceId(baseUrl = authSession.baseUrl, apiKey = authSession.apiKey)) {
|
||||
is BoardsApiResult.Success -> workspaceResult.value
|
||||
is BoardsApiResult.Failure -> return workspaceResult
|
||||
}
|
||||
|
||||
return BoardsApiResult.Success(
|
||||
SessionSnapshot(baseUrl = baseUrl, apiKey = apiKey, workspaceId = workspaceId),
|
||||
SessionSnapshot(baseUrl = authSession.baseUrl, apiKey = authSession.apiKey, workspaceId = workspaceId),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun authSession(): BoardsApiResult<AuthSessionSnapshot> {
|
||||
val baseUrl = sessionStore.getBaseUrl()?.takeIf { it.isNotBlank() }
|
||||
?: return BoardsApiResult.Failure(MISSING_SESSION_MESSAGE)
|
||||
val apiKey = withContext(ioDispatcher) {
|
||||
apiKeyStore.getApiKey(baseUrl)
|
||||
}.getOrNull()?.takeIf { it.isNotBlank() }
|
||||
?: return BoardsApiResult.Failure(MISSING_SESSION_MESSAGE)
|
||||
return BoardsApiResult.Success(AuthSessionSnapshot(baseUrl = baseUrl, apiKey = apiKey))
|
||||
}
|
||||
|
||||
private suspend fun resolveWorkspaceId(baseUrl: String, apiKey: String): BoardsApiResult<String> {
|
||||
val storedWorkspaceId = sessionStore.getWorkspaceId()?.takeIf { it.isNotBlank() }
|
||||
if (storedWorkspaceId != null) {
|
||||
@@ -101,9 +172,71 @@ class BoardsRepository(
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveActiveWorkspaceForDrawer(
|
||||
workspaces: List<WorkspaceSummary>,
|
||||
workspacesError: String?,
|
||||
): WorkspaceResolution {
|
||||
val storedWorkspaceId = sessionStore.getWorkspaceId()?.takeIf { it.isNotBlank() }
|
||||
if (workspacesError != null) {
|
||||
return WorkspaceResolution(activeWorkspaceId = storedWorkspaceId, didFallback = false)
|
||||
}
|
||||
if (workspaces.isEmpty()) {
|
||||
if (storedWorkspaceId != null) {
|
||||
sessionStore.clearWorkspaceId()
|
||||
}
|
||||
return WorkspaceResolution(activeWorkspaceId = null, didFallback = false)
|
||||
}
|
||||
|
||||
if (storedWorkspaceId != null && workspaces.any { it.id == storedWorkspaceId }) {
|
||||
return WorkspaceResolution(activeWorkspaceId = storedWorkspaceId, didFallback = false)
|
||||
}
|
||||
|
||||
val fallbackWorkspaceId = workspaces.first().id
|
||||
sessionStore.saveWorkspaceId(fallbackWorkspaceId)
|
||||
return WorkspaceResolution(activeWorkspaceId = fallbackWorkspaceId, didFallback = true)
|
||||
}
|
||||
|
||||
private fun deriveDrawerErrorCode(profileError: String?, workspacesError: String?): DrawerDataErrorCode {
|
||||
val errors = listOfNotNull(profileError, workspacesError)
|
||||
if (errors.isEmpty()) {
|
||||
return DrawerDataErrorCode.NONE
|
||||
}
|
||||
if (errors.any { isUnauthorizedMessage(it) }) {
|
||||
return DrawerDataErrorCode.UNAUTHORIZED
|
||||
}
|
||||
if (errors.any { isNetworkMessage(it) }) {
|
||||
return DrawerDataErrorCode.NETWORK
|
||||
}
|
||||
return DrawerDataErrorCode.SERVER
|
||||
}
|
||||
|
||||
private fun isUnauthorizedMessage(message: String): Boolean {
|
||||
val normalized = message.lowercase()
|
||||
return "401" in normalized || "403" in normalized || "authentication" in normalized || "unauthorized" in normalized
|
||||
}
|
||||
|
||||
private fun isNetworkMessage(message: String): Boolean {
|
||||
val normalized = message.lowercase()
|
||||
return "cannot reach server" in normalized || "connection" in normalized || "timeout" in normalized
|
||||
}
|
||||
|
||||
private data class AuthSessionSnapshot(
|
||||
val baseUrl: String,
|
||||
val apiKey: String,
|
||||
)
|
||||
|
||||
private data class WorkspaceResolution(
|
||||
val activeWorkspaceId: String?,
|
||||
val didFallback: Boolean,
|
||||
)
|
||||
|
||||
private data class SessionSnapshot(
|
||||
val baseUrl: String,
|
||||
val apiKey: String,
|
||||
val workspaceId: String,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
private const val MISSING_SESSION_MESSAGE = "Missing session. Please sign in again."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,11 +19,13 @@ data class BoardsUiState(
|
||||
val boards: List<BoardSummary> = emptyList(),
|
||||
val templates: List<BoardTemplate> = emptyList(),
|
||||
val isTemplatesLoading: Boolean = false,
|
||||
val drawer: BoardsDrawerState = BoardsDrawerState(),
|
||||
)
|
||||
|
||||
sealed interface BoardsUiEvent {
|
||||
interface BoardsUiEvent {
|
||||
data class NavigateToBoard(val boardId: String, val boardTitle: String) : BoardsUiEvent
|
||||
data class ShowServerError(val message: String) : BoardsUiEvent
|
||||
data object ForceSignOut : BoardsUiEvent
|
||||
}
|
||||
|
||||
class BoardsViewModel(
|
||||
@@ -39,6 +41,72 @@ class BoardsViewModel(
|
||||
fetchBoards(initial = true)
|
||||
}
|
||||
|
||||
fun loadDrawerData() {
|
||||
fetchDrawerData()
|
||||
}
|
||||
|
||||
fun retryDrawerData() {
|
||||
fetchDrawerData()
|
||||
}
|
||||
|
||||
fun onWorkspaceSelected(workspaceId: String) {
|
||||
val normalizedWorkspaceId = workspaceId.trim()
|
||||
if (normalizedWorkspaceId.isBlank()) {
|
||||
return
|
||||
}
|
||||
val currentDrawer = _uiState.value.drawer
|
||||
if (currentDrawer.isWorkspaceSwitchInFlight) {
|
||||
return
|
||||
}
|
||||
val currentWorkspaceId = currentDrawer.activeWorkspaceId
|
||||
if (currentWorkspaceId == normalizedWorkspaceId) {
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
drawer = it.drawer.copy(
|
||||
activeWorkspaceId = normalizedWorkspaceId,
|
||||
isWorkspaceSwitchInFlight = true,
|
||||
isWorkspaceInteractionEnabled = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
when (val result = repository.switchWorkspace(normalizedWorkspaceId)) {
|
||||
is BoardsApiResult.Success -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
drawer = it.drawer.copy(
|
||||
isWorkspaceSwitchInFlight = false,
|
||||
isWorkspaceInteractionEnabled = it.drawer.workspaces.isNotEmpty(),
|
||||
errorCode = DrawerDataErrorCode.NONE,
|
||||
),
|
||||
)
|
||||
}
|
||||
fetchBoards(initial = false, refresh = true)
|
||||
}
|
||||
|
||||
is BoardsApiResult.Failure -> {
|
||||
if (!currentWorkspaceId.isNullOrBlank()) {
|
||||
repository.switchWorkspace(currentWorkspaceId)
|
||||
}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
drawer = it.drawer.copy(
|
||||
activeWorkspaceId = currentWorkspaceId,
|
||||
isWorkspaceSwitchInFlight = false,
|
||||
isWorkspaceInteractionEnabled = it.drawer.workspaces.isNotEmpty(),
|
||||
),
|
||||
)
|
||||
}
|
||||
_events.emit(BoardsUiEvent.ShowServerError(result.message))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshBoards() {
|
||||
fetchBoards(initial = false, refresh = true)
|
||||
}
|
||||
@@ -144,6 +212,50 @@ class BoardsViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchDrawerData() {
|
||||
if (_uiState.value.drawer.isLoading) {
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
drawer = it.drawer.copy(isLoading = true),
|
||||
)
|
||||
}
|
||||
|
||||
val result = repository.loadDrawerData()
|
||||
val interactionEnabled = result.workspacesError == null && result.workspaces.isNotEmpty() && result.activeWorkspaceId != null
|
||||
val retryable = result.errorCode == DrawerDataErrorCode.NETWORK ||
|
||||
(result.profileError != null || result.workspacesError != null)
|
||||
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
drawer = it.drawer.copy(
|
||||
isLoading = false,
|
||||
profile = result.profile,
|
||||
workspaces = result.workspaces,
|
||||
activeWorkspaceId = result.activeWorkspaceId,
|
||||
profileError = result.profileError,
|
||||
workspacesError = result.workspacesError,
|
||||
errorCode = result.errorCode,
|
||||
isRetryable = retryable,
|
||||
isWorkspaceInteractionEnabled = interactionEnabled,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (result.errorCode == DrawerDataErrorCode.UNAUTHORIZED) {
|
||||
_events.emit(BoardsUiEvent.ForceSignOut)
|
||||
return@launch
|
||||
}
|
||||
|
||||
if (result.didFallbackWorkspace && result.activeWorkspaceId != null) {
|
||||
fetchBoards(initial = false, refresh = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refetchBoardsAfterMutation() {
|
||||
when (val boardsResult = repository.listBoards()) {
|
||||
is BoardsApiResult.Success -> {
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
package space.hackenslacker.kanbn4droid.app.auth
|
||||
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.OutputStream
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.ServerSocket
|
||||
import java.net.Socket
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
|
||||
import space.hackenslacker.kanbn4droid.app.boards.DrawerProfile
|
||||
|
||||
class HttpKanbnApiClientUsersMeParsingTest {
|
||||
|
||||
@Test
|
||||
fun getCurrentUser_parsesProfileFromWrappedPayload() = runTest {
|
||||
TestServer().use { server ->
|
||||
server.register(
|
||||
path = "/api/v1/users/me",
|
||||
method = "GET",
|
||||
status = 200,
|
||||
responseBody =
|
||||
"""
|
||||
{
|
||||
"data": {
|
||||
"user": {
|
||||
"name": "Alice",
|
||||
"email": "alice@example.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
val result = HttpKanbnApiClient().getCurrentUser(server.baseUrl, "api")
|
||||
|
||||
assertTrue(result is BoardsApiResult.Success<*>)
|
||||
val profile = (result as BoardsApiResult.Success<*>).value as DrawerProfile
|
||||
assertEquals("Alice", profile.displayName)
|
||||
assertEquals("alice@example.com", profile.email)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getCurrentUser_usesFallbackKeys_usernameNameEmail() = runTest {
|
||||
TestServer().use { server ->
|
||||
server.registerSequence(
|
||||
path = "/api/v1/users/me",
|
||||
method = "GET",
|
||||
responses = listOf(
|
||||
200 to """{"username":"alpha","email":"a@example.com"}""",
|
||||
200 to """{"name":"beta","email":"b@example.com"}""",
|
||||
200 to """{"email":"c@example.com"}""",
|
||||
),
|
||||
)
|
||||
|
||||
val client = HttpKanbnApiClient()
|
||||
val first = client.getCurrentUser(server.baseUrl, "api")
|
||||
val second = client.getCurrentUser(server.baseUrl, "api")
|
||||
val third = client.getCurrentUser(server.baseUrl, "api")
|
||||
|
||||
assertEquals("alpha", (first as BoardsApiResult.Success<DrawerProfile>).value.displayName)
|
||||
assertEquals("beta", (second as BoardsApiResult.Success<DrawerProfile>).value.displayName)
|
||||
assertEquals("c@example.com", (third as BoardsApiResult.Success<DrawerProfile>).value.displayName)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getCurrentUser_usesServerMessageOnFailure() = runTest {
|
||||
TestServer().use { server ->
|
||||
server.register(
|
||||
path = "/api/v1/users/me",
|
||||
method = "GET",
|
||||
status = 403,
|
||||
responseBody = """{"message":"Invalid API key"}""",
|
||||
)
|
||||
|
||||
val result = HttpKanbnApiClient().getCurrentUser(server.baseUrl, "api")
|
||||
|
||||
assertTrue(result is BoardsApiResult.Failure)
|
||||
assertEquals("Invalid API key", (result as BoardsApiResult.Failure).message)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getCurrentUser_requestMappingUsesExpectedEndpointAndApiKeyHeader() = runTest {
|
||||
TestServer().use { server ->
|
||||
server.register(path = "/api/v1/users/me", method = "GET", status = 200, responseBody = """{"username":"mapped"}""")
|
||||
|
||||
val result = HttpKanbnApiClient().getCurrentUser(server.baseUrl, "api-123")
|
||||
|
||||
assertTrue(result is BoardsApiResult.Success<*>)
|
||||
val request = server.findRequest("GET", "/api/v1/users/me")
|
||||
assertNotNull(request)
|
||||
assertEquals("api-123", request?.apiKey)
|
||||
}
|
||||
}
|
||||
|
||||
private data class CapturedRequest(
|
||||
val method: String,
|
||||
val path: String,
|
||||
val body: String,
|
||||
val apiKey: String?,
|
||||
)
|
||||
|
||||
private class TestServer : AutoCloseable {
|
||||
private val requests = CopyOnWriteArrayList<CapturedRequest>()
|
||||
private val responses = mutableMapOf<String, Pair<Int, String>>()
|
||||
private val responseSequences = mutableMapOf<String, ArrayDeque<Pair<Int, String>>>()
|
||||
private val running = AtomicBoolean(true)
|
||||
private val serverSocket = ServerSocket().apply {
|
||||
bind(InetSocketAddress("127.0.0.1", 0))
|
||||
}
|
||||
private val executor = Executors.newSingleThreadExecutor()
|
||||
|
||||
val baseUrl: String = "http://127.0.0.1:${serverSocket.localPort}"
|
||||
|
||||
init {
|
||||
executor.execute {
|
||||
while (running.get()) {
|
||||
val socket = try {
|
||||
serverSocket.accept()
|
||||
} catch (_: Throwable) {
|
||||
if (!running.get()) {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
handle(socket)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun register(path: String, method: String, status: Int, responseBody: String) {
|
||||
responses["${method.uppercase()} $path"] = status to responseBody
|
||||
}
|
||||
|
||||
fun registerSequence(path: String, method: String, responses: List<Pair<Int, String>>) {
|
||||
responseSequences["${method.uppercase()} $path"] = ArrayDeque(responses)
|
||||
}
|
||||
|
||||
fun findRequest(method: String, path: String): CapturedRequest? {
|
||||
return requests.firstOrNull { it.method == method && it.path == path }
|
||||
}
|
||||
|
||||
private fun handle(socket: Socket) {
|
||||
socket.use { s ->
|
||||
s.soTimeout = 3_000
|
||||
val input = BufferedInputStream(s.getInputStream())
|
||||
val output = s.getOutputStream()
|
||||
|
||||
val requestLine = readHttpLine(input).orEmpty()
|
||||
if (requestLine.isBlank()) {
|
||||
return
|
||||
}
|
||||
val parts = requestLine.split(" ")
|
||||
val method = parts.getOrNull(0).orEmpty()
|
||||
val path = parts.getOrNull(1).orEmpty()
|
||||
|
||||
var apiKey: String? = null
|
||||
var contentLength = 0
|
||||
while (true) {
|
||||
val line = readHttpLine(input).orEmpty()
|
||||
if (line.isBlank()) {
|
||||
break
|
||||
}
|
||||
val separatorIndex = line.indexOf(':')
|
||||
if (separatorIndex <= 0) {
|
||||
continue
|
||||
}
|
||||
val headerName = line.substring(0, separatorIndex).trim().lowercase()
|
||||
val headerValue = line.substring(separatorIndex + 1).trim()
|
||||
if (headerName == "x-api-key") {
|
||||
apiKey = headerValue
|
||||
} else if (headerName == "content-length") {
|
||||
contentLength = headerValue.toIntOrNull() ?: 0
|
||||
}
|
||||
}
|
||||
|
||||
val bodyBytes = if (contentLength > 0) ByteArray(contentLength) else ByteArray(0)
|
||||
if (contentLength > 0) {
|
||||
var total = 0
|
||||
while (total < contentLength) {
|
||||
val read = input.read(bodyBytes, total, contentLength - total)
|
||||
if (read <= 0) {
|
||||
break
|
||||
}
|
||||
total += read
|
||||
}
|
||||
}
|
||||
val body = String(bodyBytes)
|
||||
requests += CapturedRequest(method = method, path = path, body = body, apiKey = apiKey)
|
||||
|
||||
val sequenceKey = "$method $path"
|
||||
val sequence = responseSequences[sequenceKey]
|
||||
val sequencedResponse = if (sequence != null && sequence.isNotEmpty()) sequence.removeFirst() else null
|
||||
val response = sequencedResponse ?: responses[sequenceKey] ?: (404 to "")
|
||||
writeResponse(output, response.first, response.second)
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeResponse(output: OutputStream, status: Int, body: String) {
|
||||
val bytes = body.toByteArray()
|
||||
val reason = when (status) {
|
||||
200 -> "OK"
|
||||
403 -> "Forbidden"
|
||||
404 -> "Not Found"
|
||||
else -> "Error"
|
||||
}
|
||||
val responseHeaders =
|
||||
"HTTP/1.1 $status $reason\r\n" +
|
||||
"Content-Type: application/json\r\n" +
|
||||
"Content-Length: ${bytes.size}\r\n" +
|
||||
"Connection: close\r\n\r\n"
|
||||
output.write(responseHeaders.toByteArray())
|
||||
output.write(bytes)
|
||||
output.flush()
|
||||
}
|
||||
|
||||
private fun readHttpLine(input: BufferedInputStream): String? {
|
||||
val builder = StringBuilder()
|
||||
while (true) {
|
||||
val next = input.read()
|
||||
if (next == -1) {
|
||||
return if (builder.isEmpty()) null else builder.toString()
|
||||
}
|
||||
if (next == '\n'.code) {
|
||||
if (builder.isNotEmpty() && builder.last() == '\r') {
|
||||
builder.deleteCharAt(builder.length - 1)
|
||||
}
|
||||
return builder.toString()
|
||||
}
|
||||
builder.append(next.toChar())
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
running.set(false)
|
||||
serverSocket.close()
|
||||
executor.shutdownNow()
|
||||
executor.awaitTermination(3, TimeUnit.SECONDS)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
package space.hackenslacker.kanbn4droid.app.boards
|
||||
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
@@ -112,9 +113,217 @@ class BoardsViewModelTest {
|
||||
assertFalse(viewModel.uiState.value.isMutating)
|
||||
}
|
||||
|
||||
private fun newViewModel(apiClient: FakeBoardsApiClient): BoardsViewModel {
|
||||
@Test
|
||||
fun loadDrawerDataSuccessPopulatesProfileAndWorkspaces() = runTest {
|
||||
val api = FakeBoardsApiClient().apply {
|
||||
usersMeResult = BoardsApiResult.Success(DrawerProfile(displayName = "Alice", email = "alice@example.com"))
|
||||
workspacesResult = BoardsApiResult.Success(
|
||||
listOf(
|
||||
WorkspaceSummary("ws-1", "Main"),
|
||||
WorkspaceSummary("ws-2", "Platform"),
|
||||
),
|
||||
)
|
||||
}
|
||||
val sessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = "ws-2")
|
||||
val viewModel = newViewModel(api, sessionStore = sessionStore)
|
||||
|
||||
viewModel.loadDrawerData()
|
||||
advanceUntilIdle()
|
||||
|
||||
val drawer = viewModel.uiState.value.drawer
|
||||
assertEquals("Alice", drawer.profile?.displayName)
|
||||
assertEquals(2, drawer.workspaces.size)
|
||||
assertEquals("ws-2", drawer.activeWorkspaceId)
|
||||
assertEquals(DrawerDataErrorCode.NONE, drawer.errorCode)
|
||||
assertTrue(drawer.isWorkspaceInteractionEnabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun workspaceSwitchIgnoresSecondTapWhileInFlight() = runTest {
|
||||
val api = FakeBoardsApiClient().apply {
|
||||
usersMeResult = BoardsApiResult.Success(DrawerProfile(displayName = "Alice", email = null))
|
||||
workspacesResult = BoardsApiResult.Success(
|
||||
listOf(
|
||||
WorkspaceSummary("ws-1", "Main"),
|
||||
WorkspaceSummary("ws-2", "Platform"),
|
||||
),
|
||||
)
|
||||
listBoardsResult = BoardsApiResult.Success(emptyList())
|
||||
blockListBoards = CompletableDeferred()
|
||||
}
|
||||
val sessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = "ws-1")
|
||||
val viewModel = newViewModel(api, sessionStore = sessionStore)
|
||||
viewModel.loadDrawerData()
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onWorkspaceSelected("ws-2")
|
||||
viewModel.onWorkspaceSelected("ws-1")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals("ws-2", viewModel.uiState.value.drawer.activeWorkspaceId)
|
||||
assertEquals("ws-2", sessionStore.getWorkspaceId())
|
||||
assertEquals(1, api.listBoardsCalls)
|
||||
api.blockListBoards?.complete(Unit)
|
||||
advanceUntilIdle()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun drawerUnauthorizedEmitsSignOutEvent() = runTest {
|
||||
val api = FakeBoardsApiClient().apply {
|
||||
usersMeResult = BoardsApiResult.Failure("Server error: 401")
|
||||
workspacesResult = BoardsApiResult.Failure("Server error: 401")
|
||||
}
|
||||
val viewModel = newViewModel(api)
|
||||
|
||||
val eventDeferred = async { viewModel.events.first() }
|
||||
viewModel.loadDrawerData()
|
||||
advanceUntilIdle()
|
||||
|
||||
val event = eventDeferred.await()
|
||||
assertTrue(event is BoardsUiEvent.ForceSignOut)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadDrawerDataEmptyWorkspaceListSetsEmptyState() = runTest {
|
||||
val api = FakeBoardsApiClient().apply {
|
||||
usersMeResult = BoardsApiResult.Success(DrawerProfile(displayName = "Alice", email = null))
|
||||
workspacesResult = BoardsApiResult.Success(emptyList())
|
||||
}
|
||||
val sessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = "ws-stale")
|
||||
val viewModel = newViewModel(api, sessionStore = sessionStore)
|
||||
|
||||
viewModel.loadDrawerData()
|
||||
advanceUntilIdle()
|
||||
|
||||
val drawer = viewModel.uiState.value.drawer
|
||||
assertTrue(drawer.workspaces.isEmpty())
|
||||
assertEquals(null, drawer.activeWorkspaceId)
|
||||
assertFalse(drawer.isWorkspaceInteractionEnabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadDrawerDataEmptyWorkspaceDisablesWorkspaceInteraction() = runTest {
|
||||
val api = FakeBoardsApiClient().apply {
|
||||
usersMeResult = BoardsApiResult.Success(DrawerProfile(displayName = "Alice", email = null))
|
||||
workspacesResult = BoardsApiResult.Failure("Server error: 500")
|
||||
}
|
||||
val sessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = null)
|
||||
val viewModel = newViewModel(api, sessionStore = sessionStore)
|
||||
|
||||
viewModel.loadDrawerData()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertFalse(viewModel.uiState.value.drawer.isWorkspaceInteractionEnabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadDrawerDataPartialFailureExposesRetryableState() = runTest {
|
||||
val api = FakeBoardsApiClient().apply {
|
||||
usersMeResult = BoardsApiResult.Failure("Cannot reach server. Check your connection and URL.")
|
||||
workspacesResult = BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main")))
|
||||
}
|
||||
val viewModel = newViewModel(api)
|
||||
|
||||
viewModel.loadDrawerData()
|
||||
advanceUntilIdle()
|
||||
|
||||
val drawer = viewModel.uiState.value.drawer
|
||||
assertEquals(DrawerDataErrorCode.NETWORK, drawer.errorCode)
|
||||
assertTrue(drawer.isRetryable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadDrawerDataWorkspacesFailureDisablesWorkspaceInteraction() = runTest {
|
||||
val api = FakeBoardsApiClient().apply {
|
||||
usersMeResult = BoardsApiResult.Success(DrawerProfile(displayName = "Alice", email = null))
|
||||
workspacesResult = BoardsApiResult.Failure("Server error: 503")
|
||||
}
|
||||
val sessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = "ws-1")
|
||||
val viewModel = newViewModel(api, sessionStore = sessionStore)
|
||||
|
||||
viewModel.loadDrawerData()
|
||||
advanceUntilIdle()
|
||||
|
||||
val drawer = viewModel.uiState.value.drawer
|
||||
assertEquals("ws-1", drawer.activeWorkspaceId)
|
||||
assertFalse(drawer.isWorkspaceInteractionEnabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun retryDrawerDataAfterFailureSucceeds() = runTest {
|
||||
val api = FakeBoardsApiClient().apply {
|
||||
usersMeResults.addLast(BoardsApiResult.Failure("Cannot reach server. Check your connection and URL."))
|
||||
usersMeResults.addLast(BoardsApiResult.Success(DrawerProfile(displayName = "Alice", email = null)))
|
||||
workspacesResults.addLast(BoardsApiResult.Failure("Cannot reach server. Check your connection and URL."))
|
||||
workspacesResults.addLast(BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main"))))
|
||||
}
|
||||
val viewModel = newViewModel(api)
|
||||
|
||||
viewModel.loadDrawerData()
|
||||
advanceUntilIdle()
|
||||
assertTrue(viewModel.uiState.value.drawer.isRetryable)
|
||||
|
||||
viewModel.retryDrawerData()
|
||||
advanceUntilIdle()
|
||||
|
||||
val drawer = viewModel.uiState.value.drawer
|
||||
assertEquals(DrawerDataErrorCode.NONE, drawer.errorCode)
|
||||
assertFalse(drawer.isRetryable)
|
||||
assertEquals("Alice", drawer.profile?.displayName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadDrawerDataFallbackWorkspacePersistsFirstAndTriggersBoardsRefresh() = runTest {
|
||||
val api = FakeBoardsApiClient().apply {
|
||||
usersMeResult = BoardsApiResult.Success(DrawerProfile(displayName = "Alice", email = null))
|
||||
workspacesResult = BoardsApiResult.Success(
|
||||
listOf(
|
||||
WorkspaceSummary("ws-1", "Main"),
|
||||
WorkspaceSummary("ws-2", "Platform"),
|
||||
),
|
||||
)
|
||||
listBoardsResult = BoardsApiResult.Success(emptyList())
|
||||
}
|
||||
val sessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = "ws-missing")
|
||||
val viewModel = newViewModel(api, sessionStore = sessionStore)
|
||||
|
||||
viewModel.loadDrawerData()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals("ws-1", sessionStore.getWorkspaceId())
|
||||
assertEquals(1, api.listBoardsCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun workspaceSwitchFailureRestoresUiAndPersistedWorkspaceId() = runTest {
|
||||
val api = FakeBoardsApiClient().apply {
|
||||
usersMeResult = BoardsApiResult.Success(DrawerProfile(displayName = "Alice", email = null))
|
||||
workspacesResult = BoardsApiResult.Success(
|
||||
listOf(
|
||||
WorkspaceSummary("ws-1", "Main"),
|
||||
WorkspaceSummary("ws-2", "Platform"),
|
||||
),
|
||||
)
|
||||
listBoardsResults.addLast(BoardsApiResult.Failure("Server error: 500"))
|
||||
}
|
||||
val sessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = "ws-1")
|
||||
val viewModel = newViewModel(api, sessionStore = sessionStore)
|
||||
|
||||
viewModel.loadDrawerData()
|
||||
advanceUntilIdle()
|
||||
viewModel.onWorkspaceSelected("ws-2")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals("ws-1", viewModel.uiState.value.drawer.activeWorkspaceId)
|
||||
assertEquals("ws-1", sessionStore.getWorkspaceId())
|
||||
}
|
||||
|
||||
private fun newViewModel(
|
||||
apiClient: FakeBoardsApiClient,
|
||||
sessionStore: InMemorySessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = "ws-1"),
|
||||
): BoardsViewModel {
|
||||
val repository = BoardsRepository(
|
||||
sessionStore = InMemorySessionStore("https://kan.bn/"),
|
||||
sessionStore = sessionStore,
|
||||
apiKeyStore = InMemoryApiKeyStore("api"),
|
||||
apiClient = apiClient,
|
||||
ioDispatcher = UnconfinedTestDispatcher(),
|
||||
@@ -122,15 +331,19 @@ class BoardsViewModelTest {
|
||||
return BoardsViewModel(repository)
|
||||
}
|
||||
|
||||
private class InMemorySessionStore(private var baseUrl: String?) : SessionStore {
|
||||
private class InMemorySessionStore(
|
||||
private var baseUrl: String?,
|
||||
private var workspaceId: String? = null,
|
||||
) : SessionStore {
|
||||
override fun getBaseUrl(): String? = baseUrl
|
||||
override fun saveBaseUrl(url: String) {
|
||||
baseUrl = url
|
||||
}
|
||||
|
||||
override fun getWorkspaceId(): String? = "ws-1"
|
||||
override fun getWorkspaceId(): String? = workspaceId
|
||||
|
||||
override fun saveWorkspaceId(workspaceId: String) {
|
||||
this.workspaceId = workspaceId
|
||||
}
|
||||
|
||||
override fun clearBaseUrl() {
|
||||
@@ -138,6 +351,7 @@ class BoardsViewModelTest {
|
||||
}
|
||||
|
||||
override fun clearWorkspaceId() {
|
||||
workspaceId = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,20 +371,39 @@ class BoardsViewModelTest {
|
||||
|
||||
private class FakeBoardsApiClient : KanbnApiClient {
|
||||
var listBoardsResult: BoardsApiResult<List<BoardSummary>> = BoardsApiResult.Success(emptyList())
|
||||
val listBoardsResults = ArrayDeque<BoardsApiResult<List<BoardSummary>>>()
|
||||
var createBoardResult: BoardsApiResult<BoardSummary> = BoardsApiResult.Success(BoardSummary("new", "New"))
|
||||
var listTemplatesResult: BoardsApiResult<List<BoardTemplate>> = BoardsApiResult.Success(emptyList())
|
||||
var deleteBoardResult: BoardsApiResult<Unit> = BoardsApiResult.Success(Unit)
|
||||
var workspacesResult: BoardsApiResult<List<WorkspaceSummary>> = BoardsApiResult.Success(emptyList())
|
||||
val workspacesResults = ArrayDeque<BoardsApiResult<List<WorkspaceSummary>>>()
|
||||
var usersMeResult: BoardsApiResult<DrawerProfile> = BoardsApiResult.Success(DrawerProfile("User", null))
|
||||
val usersMeResults = ArrayDeque<BoardsApiResult<DrawerProfile>>()
|
||||
var blockListBoards: CompletableDeferred<Unit>? = null
|
||||
|
||||
var lastDeletedId: String? = null
|
||||
|
||||
var listBoardsCalls: Int = 0
|
||||
override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = AuthResult.Success
|
||||
|
||||
override suspend fun getCurrentUser(baseUrl: String, apiKey: String): BoardsApiResult<DrawerProfile> {
|
||||
return usersMeResults.removeFirstOrNull() ?: usersMeResult
|
||||
}
|
||||
|
||||
override suspend fun listWorkspaces(
|
||||
baseUrl: String,
|
||||
apiKey: String,
|
||||
): BoardsApiResult<List<WorkspaceSummary>> {
|
||||
return workspacesResults.removeFirstOrNull() ?: workspacesResult
|
||||
}
|
||||
|
||||
override suspend fun listBoards(
|
||||
baseUrl: String,
|
||||
apiKey: String,
|
||||
workspaceId: String,
|
||||
): BoardsApiResult<List<BoardSummary>> {
|
||||
return listBoardsResult
|
||||
listBoardsCalls += 1
|
||||
blockListBoards?.await()
|
||||
return listBoardsResults.removeFirstOrNull() ?: listBoardsResult
|
||||
}
|
||||
|
||||
override suspend fun listBoardTemplates(
|
||||
|
||||
Reference in New Issue
Block a user