diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/KanbnApiClient.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/KanbnApiClient.kt index eca7e55..03e4b29 100644 --- a/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/KanbnApiClient.kt +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/KanbnApiClient.kt @@ -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 { + return BoardsApiResult.Failure("Current user endpoint is not implemented.") + } + suspend fun listWorkspaces(baseUrl: String, apiKey: String): BoardsApiResult> { 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 { + 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> { return withContext(Dispatchers.IO) { request( diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/UsersMeParser.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/UsersMeParser.kt new file mode 100644 index 0000000..74d8d8c --- /dev/null +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/UsersMeParser.kt @@ -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? { + val parsed = parseUsersMeJsonValue(body) + @Suppress("UNCHECKED_CAST") + return parsed as? Map +} + +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 { + expect('{') + skipWhitespace() + val result = linkedMapOf() + 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 { + expect('[') + skipWhitespace() + val result = mutableListOf() + 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 + } + } +} diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsDrawerModels.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsDrawerModels.kt new file mode 100644 index 0000000..e91b219 --- /dev/null +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsDrawerModels.kt @@ -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, + 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 = 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, +) diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsRepository.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsRepository.kt index 4fe1d47..a1b6d14 100644 --- a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsRepository.kt +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsRepository.kt @@ -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 { + 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> { val session = when (val sessionResult = session()) { is BoardsApiResult.Success -> sessionResult.value @@ -66,23 +129,31 @@ class BoardsRepository( } private suspend fun session(): BoardsApiResult { - 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 { + 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 { val storedWorkspaceId = sessionStore.getWorkspaceId()?.takeIf { it.isNotBlank() } if (storedWorkspaceId != null) { @@ -101,9 +172,71 @@ class BoardsRepository( } } + private fun resolveActiveWorkspaceForDrawer( + workspaces: List, + 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." + } } diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsViewModel.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsViewModel.kt index 0ad0ac8..a0eb5b5 100644 --- a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsViewModel.kt +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsViewModel.kt @@ -19,11 +19,13 @@ data class BoardsUiState( val boards: List = emptyList(), val templates: List = 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 -> { diff --git a/app/src/test/java/space/hackenslacker/kanbn4droid/app/auth/HttpKanbnApiClientUsersMeParsingTest.kt b/app/src/test/java/space/hackenslacker/kanbn4droid/app/auth/HttpKanbnApiClientUsersMeParsingTest.kt new file mode 100644 index 0000000..c77266e --- /dev/null +++ b/app/src/test/java/space/hackenslacker/kanbn4droid/app/auth/HttpKanbnApiClientUsersMeParsingTest.kt @@ -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).value.displayName) + assertEquals("beta", (second as BoardsApiResult.Success).value.displayName) + assertEquals("c@example.com", (third as BoardsApiResult.Success).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() + private val responses = mutableMapOf>() + private val responseSequences = mutableMapOf>>() + 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>) { + 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) + } + } +} diff --git a/app/src/test/java/space/hackenslacker/kanbn4droid/app/boards/BoardsViewModelTest.kt b/app/src/test/java/space/hackenslacker/kanbn4droid/app/boards/BoardsViewModelTest.kt index f06b0af..92e0055 100644 --- a/app/src/test/java/space/hackenslacker/kanbn4droid/app/boards/BoardsViewModelTest.kt +++ b/app/src/test/java/space/hackenslacker/kanbn4droid/app/boards/BoardsViewModelTest.kt @@ -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> = BoardsApiResult.Success(emptyList()) + val listBoardsResults = ArrayDeque>>() var createBoardResult: BoardsApiResult = BoardsApiResult.Success(BoardSummary("new", "New")) var listTemplatesResult: BoardsApiResult> = BoardsApiResult.Success(emptyList()) var deleteBoardResult: BoardsApiResult = BoardsApiResult.Success(Unit) + var workspacesResult: BoardsApiResult> = BoardsApiResult.Success(emptyList()) + val workspacesResults = ArrayDeque>>() + var usersMeResult: BoardsApiResult = BoardsApiResult.Success(DrawerProfile("User", null)) + val usersMeResults = ArrayDeque>() + var blockListBoards: CompletableDeferred? = 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 { + return usersMeResults.removeFirstOrNull() ?: usersMeResult + } + + override suspend fun listWorkspaces( + baseUrl: String, + apiKey: String, + ): BoardsApiResult> { + return workspacesResults.removeFirstOrNull() ?: workspacesResult + } + override suspend fun listBoards( baseUrl: String, apiKey: String, workspaceId: String, ): BoardsApiResult> { - return listBoardsResult + listBoardsCalls += 1 + blockListBoards?.await() + return listBoardsResults.removeFirstOrNull() ?: listBoardsResult } override suspend fun listBoardTemplates(