feat: add boards drawer profile and workspace state flow

This commit is contained in:
2026-03-18 08:46:22 -04:00
parent 2daad8e7ac
commit f27aa6969d
7 changed files with 1009 additions and 16 deletions

View File

@@ -19,6 +19,7 @@ import space.hackenslacker.kanbn4droid.app.boarddetail.CreatedEntityRef
import space.hackenslacker.kanbn4droid.app.boards.BoardSummary import space.hackenslacker.kanbn4droid.app.boards.BoardSummary
import space.hackenslacker.kanbn4droid.app.boards.BoardTemplate import space.hackenslacker.kanbn4droid.app.boards.BoardTemplate
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult 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.boards.WorkspaceSummary
import space.hackenslacker.kanbn4droid.app.carddetail.CardActivity import space.hackenslacker.kanbn4droid.app.carddetail.CardActivity
import space.hackenslacker.kanbn4droid.app.carddetail.CardDetail import space.hackenslacker.kanbn4droid.app.carddetail.CardDetail
@@ -27,6 +28,10 @@ import space.hackenslacker.kanbn4droid.app.carddetail.CardDetailTag
interface KanbnApiClient { interface KanbnApiClient {
suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult 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>> { suspend fun listWorkspaces(baseUrl: String, apiKey: String): BoardsApiResult<List<WorkspaceSummary>> {
return BoardsApiResult.Failure("Workspace listing is not implemented.") return BoardsApiResult.Failure("Workspace listing is not implemented.")
} }
@@ -134,6 +139,25 @@ data class LabelDetail(
class HttpKanbnApiClient : KanbnApiClient { 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>> { override suspend fun listWorkspaces(baseUrl: String, apiKey: String): BoardsApiResult<List<WorkspaceSummary>> {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
request( request(

View File

@@ -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
}
}
}

View File

@@ -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,
)

View File

@@ -13,6 +13,69 @@ class BoardsRepository(
private val apiClient: KanbnApiClient, private val apiClient: KanbnApiClient,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, 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>> { suspend fun listBoards(): BoardsApiResult<List<BoardSummary>> {
val session = when (val sessionResult = session()) { val session = when (val sessionResult = session()) {
is BoardsApiResult.Success -> sessionResult.value is BoardsApiResult.Success -> sessionResult.value
@@ -66,23 +129,31 @@ class BoardsRepository(
} }
private suspend fun session(): BoardsApiResult<SessionSnapshot> { private suspend fun session(): BoardsApiResult<SessionSnapshot> {
val baseUrl = sessionStore.getBaseUrl()?.takeIf { it.isNotBlank() } val authSession = when (val sessionResult = authSession()) {
?: return BoardsApiResult.Failure("Missing session. Please sign in again.") is BoardsApiResult.Success -> sessionResult.value
val apiKey = withContext(ioDispatcher) { is BoardsApiResult.Failure -> return sessionResult
apiKeyStore.getApiKey(baseUrl) }
}.getOrNull()?.takeIf { it.isNotBlank() }
?: return BoardsApiResult.Failure("Missing session. Please sign in again.")
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.Success -> workspaceResult.value
is BoardsApiResult.Failure -> return workspaceResult is BoardsApiResult.Failure -> return workspaceResult
} }
return BoardsApiResult.Success( 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> { private suspend fun resolveWorkspaceId(baseUrl: String, apiKey: String): BoardsApiResult<String> {
val storedWorkspaceId = sessionStore.getWorkspaceId()?.takeIf { it.isNotBlank() } val storedWorkspaceId = sessionStore.getWorkspaceId()?.takeIf { it.isNotBlank() }
if (storedWorkspaceId != null) { 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( private data class SessionSnapshot(
val baseUrl: String, val baseUrl: String,
val apiKey: String, val apiKey: String,
val workspaceId: String, val workspaceId: String,
) )
private companion object {
private const val MISSING_SESSION_MESSAGE = "Missing session. Please sign in again."
}
} }

View File

@@ -19,11 +19,13 @@ data class BoardsUiState(
val boards: List<BoardSummary> = emptyList(), val boards: List<BoardSummary> = emptyList(),
val templates: List<BoardTemplate> = emptyList(), val templates: List<BoardTemplate> = emptyList(),
val isTemplatesLoading: Boolean = false, 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 NavigateToBoard(val boardId: String, val boardTitle: String) : BoardsUiEvent
data class ShowServerError(val message: String) : BoardsUiEvent data class ShowServerError(val message: String) : BoardsUiEvent
data object ForceSignOut : BoardsUiEvent
} }
class BoardsViewModel( class BoardsViewModel(
@@ -39,6 +41,72 @@ class BoardsViewModel(
fetchBoards(initial = true) 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() { fun refreshBoards() {
fetchBoards(initial = false, refresh = true) 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() { private suspend fun refetchBoardsAfterMutation() {
when (val boardsResult = repository.listBoards()) { when (val boardsResult = repository.listBoards()) {
is BoardsApiResult.Success -> { is BoardsApiResult.Success -> {

View File

@@ -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)
}
}
}

View File

@@ -1,10 +1,11 @@
package space.hackenslacker.kanbn4droid.app.boards package space.hackenslacker.kanbn4droid.app.boards
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@@ -112,9 +113,217 @@ class BoardsViewModelTest {
assertFalse(viewModel.uiState.value.isMutating) 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( val repository = BoardsRepository(
sessionStore = InMemorySessionStore("https://kan.bn/"), sessionStore = sessionStore,
apiKeyStore = InMemoryApiKeyStore("api"), apiKeyStore = InMemoryApiKeyStore("api"),
apiClient = apiClient, apiClient = apiClient,
ioDispatcher = UnconfinedTestDispatcher(), ioDispatcher = UnconfinedTestDispatcher(),
@@ -122,15 +331,19 @@ class BoardsViewModelTest {
return BoardsViewModel(repository) 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 getBaseUrl(): String? = baseUrl
override fun saveBaseUrl(url: String) { override fun saveBaseUrl(url: String) {
baseUrl = url baseUrl = url
} }
override fun getWorkspaceId(): String? = "ws-1" override fun getWorkspaceId(): String? = workspaceId
override fun saveWorkspaceId(workspaceId: String) { override fun saveWorkspaceId(workspaceId: String) {
this.workspaceId = workspaceId
} }
override fun clearBaseUrl() { override fun clearBaseUrl() {
@@ -138,6 +351,7 @@ class BoardsViewModelTest {
} }
override fun clearWorkspaceId() { override fun clearWorkspaceId() {
workspaceId = null
} }
} }
@@ -157,20 +371,39 @@ class BoardsViewModelTest {
private class FakeBoardsApiClient : KanbnApiClient { private class FakeBoardsApiClient : KanbnApiClient {
var listBoardsResult: BoardsApiResult<List<BoardSummary>> = BoardsApiResult.Success(emptyList()) var listBoardsResult: BoardsApiResult<List<BoardSummary>> = BoardsApiResult.Success(emptyList())
val listBoardsResults = ArrayDeque<BoardsApiResult<List<BoardSummary>>>()
var createBoardResult: BoardsApiResult<BoardSummary> = BoardsApiResult.Success(BoardSummary("new", "New")) var createBoardResult: BoardsApiResult<BoardSummary> = BoardsApiResult.Success(BoardSummary("new", "New"))
var listTemplatesResult: BoardsApiResult<List<BoardTemplate>> = BoardsApiResult.Success(emptyList()) var listTemplatesResult: BoardsApiResult<List<BoardTemplate>> = BoardsApiResult.Success(emptyList())
var deleteBoardResult: BoardsApiResult<Unit> = BoardsApiResult.Success(Unit) 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 lastDeletedId: String? = null
var listBoardsCalls: Int = 0
override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = AuthResult.Success 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( override suspend fun listBoards(
baseUrl: String, baseUrl: String,
apiKey: String, apiKey: String,
workspaceId: String, workspaceId: String,
): BoardsApiResult<List<BoardSummary>> { ): BoardsApiResult<List<BoardSummary>> {
return listBoardsResult listBoardsCalls += 1
blockListBoards?.await()
return listBoardsResults.removeFirstOrNull() ?: listBoardsResult
} }
override suspend fun listBoardTemplates( override suspend fun listBoardTemplates(