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.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(
|
||||||
|
|||||||
@@ -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 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."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 -> {
|
||||||
|
|||||||
@@ -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
|
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(
|
||||||
|
|||||||
Reference in New Issue
Block a user