fix: scope boards API calls to resolved workspace

This commit is contained in:
2026-03-15 22:47:02 -04:00
parent 5016704627
commit c820b413aa
11 changed files with 333 additions and 28 deletions

View File

@@ -27,6 +27,7 @@ import space.hackenslacker.kanbn4droid.app.auth.SessionStore
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.WorkspaceSummary
@RunWith(AndroidJUnit4::class)
class BoardsFlowTest {
@@ -127,6 +128,14 @@ class BoardsFlowTest {
override fun clearBaseUrl() {
baseUrl = null
}
override fun getWorkspaceId(): String? = "ws-1"
override fun saveWorkspaceId(workspaceId: String) {
}
override fun clearWorkspaceId() {
}
}
private class InMemoryApiKeyStore(
@@ -151,13 +160,25 @@ class BoardsFlowTest {
) : KanbnApiClient {
override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = AuthResult.Success
override suspend fun listBoards(baseUrl: String, apiKey: String): BoardsApiResult<List<BoardSummary>> {
override suspend fun listWorkspaces(
baseUrl: String,
apiKey: String,
): BoardsApiResult<List<WorkspaceSummary>> {
return BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main")))
}
override suspend fun listBoards(
baseUrl: String,
apiKey: String,
workspaceId: String,
): BoardsApiResult<List<BoardSummary>> {
return BoardsApiResult.Success(boards.toList())
}
override suspend fun listBoardTemplates(
baseUrl: String,
apiKey: String,
workspaceId: String,
): BoardsApiResult<List<BoardTemplate>> {
return BoardsApiResult.Success(templates)
}
@@ -165,6 +186,7 @@ class BoardsFlowTest {
override suspend fun createBoard(
baseUrl: String,
apiKey: String,
workspaceId: String,
name: String,
templateId: String?,
): BoardsApiResult<BoardSummary> {

View File

@@ -163,6 +163,14 @@ class LoginFlowTest {
override fun clearBaseUrl() {
baseUrl = null
}
override fun getWorkspaceId(): String? = null
override fun saveWorkspaceId(workspaceId: String) {
}
override fun clearWorkspaceId() {
}
}
private class InMemoryApiKeyStore(

View File

@@ -159,6 +159,7 @@ class MainActivity : AppCompatActivity() {
}
if (saveKeyResult.isSuccess) {
sessionStore.saveBaseUrl(normalizedBaseUrl)
sessionStore.clearWorkspaceId()
openBoards()
} else {
loginProgress.visibility = View.GONE

View File

@@ -10,21 +10,35 @@ import kotlinx.coroutines.withContext
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.WorkspaceSummary
interface KanbnApiClient {
suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult
suspend fun listBoards(baseUrl: String, apiKey: String): BoardsApiResult<List<BoardSummary>> {
suspend fun listWorkspaces(baseUrl: String, apiKey: String): BoardsApiResult<List<WorkspaceSummary>> {
return BoardsApiResult.Failure("Workspace listing is not implemented.")
}
suspend fun listBoards(
baseUrl: String,
apiKey: String,
workspaceId: String,
): BoardsApiResult<List<BoardSummary>> {
return BoardsApiResult.Failure("Boards listing is not implemented.")
}
suspend fun listBoardTemplates(baseUrl: String, apiKey: String): BoardsApiResult<List<BoardTemplate>> {
suspend fun listBoardTemplates(
baseUrl: String,
apiKey: String,
workspaceId: String,
): BoardsApiResult<List<BoardTemplate>> {
return BoardsApiResult.Failure("Board templates listing is not implemented.")
}
suspend fun createBoard(
baseUrl: String,
apiKey: String,
workspaceId: String,
name: String,
templateId: String?,
): BoardsApiResult<BoardSummary> {
@@ -38,6 +52,23 @@ interface KanbnApiClient {
class HttpKanbnApiClient : KanbnApiClient {
override suspend fun listWorkspaces(baseUrl: String, apiKey: String): BoardsApiResult<List<WorkspaceSummary>> {
return withContext(Dispatchers.IO) {
request(
baseUrl = baseUrl,
path = "/api/v1/workspaces",
method = "GET",
apiKey = apiKey,
) { code, body ->
if (code in 200..299) {
BoardsApiResult.Success(parseWorkspaces(body))
} else {
BoardsApiResult.Failure(serverMessage(body, code))
}
}
}
}
override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult {
return withContext(Dispatchers.IO) {
val endpoint = "${baseUrl.trimEnd('/')}/api/v1/health"
@@ -67,11 +98,15 @@ class HttpKanbnApiClient : KanbnApiClient {
}
}
override suspend fun listBoards(baseUrl: String, apiKey: String): BoardsApiResult<List<BoardSummary>> {
override suspend fun listBoards(
baseUrl: String,
apiKey: String,
workspaceId: String,
): BoardsApiResult<List<BoardSummary>> {
return withContext(Dispatchers.IO) {
request(
baseUrl = baseUrl,
path = "/api/v1/boards",
path = "/api/v1/workspaces/$workspaceId/boards",
method = "GET",
apiKey = apiKey,
) { code, body ->
@@ -87,11 +122,12 @@ class HttpKanbnApiClient : KanbnApiClient {
override suspend fun listBoardTemplates(
baseUrl: String,
apiKey: String,
workspaceId: String,
): BoardsApiResult<List<BoardTemplate>> {
return withContext(Dispatchers.IO) {
request(
baseUrl = baseUrl,
path = "/api/v1/board-templates",
path = "/api/v1/workspaces/$workspaceId/boards?type=template",
method = "GET",
apiKey = apiKey,
) { code, body ->
@@ -107,18 +143,22 @@ class HttpKanbnApiClient : KanbnApiClient {
override suspend fun createBoard(
baseUrl: String,
apiKey: String,
workspaceId: String,
name: String,
templateId: String?,
): BoardsApiResult<BoardSummary> {
return withContext(Dispatchers.IO) {
val payload = JSONObject().put("title", name)
val payload = JSONObject()
.put("name", name)
.put("lists", JSONArray())
.put("labels", JSONArray())
if (!templateId.isNullOrBlank()) {
payload.put("template_id", templateId)
payload.put("sourceBoardPublicId", templateId)
}
request(
baseUrl = baseUrl,
path = "/api/v1/boards",
path = "/api/v1/workspaces/$workspaceId/boards",
method = "POST",
apiKey = apiKey,
body = payload.toString(),
@@ -222,9 +262,14 @@ class HttpKanbnApiClient : KanbnApiClient {
val boards = mutableListOf<BoardSummary>()
for (index in 0 until array.length()) {
val item = array.optJSONObject(index) ?: continue
val id = item.opt("id")?.toString().orEmpty()
val id = item.opt("id")?.toString().orEmpty().ifBlank {
item.optString("publicId")
.ifBlank { item.optString("public_id") }
}
val title = item.optString("title").ifBlank {
item.optString("name").ifBlank { "Board" }
item.optString("name").ifBlank {
item.optString("slug").ifBlank { "Board" }
}
}
if (id.isNotBlank()) {
boards += BoardSummary(id = id, title = title)
@@ -239,7 +284,11 @@ class HttpKanbnApiClient : KanbnApiClient {
}
val root = JSONObject(body)
val id = root.opt("id")?.toString().orEmpty().ifBlank { root.opt("board_id")?.toString().orEmpty() }
val id = root.opt("id")?.toString().orEmpty().ifBlank {
root.optString("publicId")
.ifBlank { root.optString("public_id") }
.ifBlank { root.opt("board_id")?.toString().orEmpty() }
}
val title = root.optString("title").ifBlank { root.optString("name").ifBlank { fallbackName } }
return BoardSummary(id = if (id.isBlank()) "new" else id, title = title)
}
@@ -262,7 +311,10 @@ class HttpKanbnApiClient : KanbnApiClient {
val templates = mutableListOf<BoardTemplate>()
for (index in 0 until array.length()) {
val item = array.optJSONObject(index) ?: continue
val id = item.opt("id")?.toString().orEmpty()
val id = item.opt("id")?.toString().orEmpty().ifBlank {
item.optString("publicId")
.ifBlank { item.optString("public_id") }
}
val name = item.optString("name").ifBlank {
item.optString("title").ifBlank { "Template" }
}
@@ -273,6 +325,42 @@ class HttpKanbnApiClient : KanbnApiClient {
return templates
}
private fun parseWorkspaces(body: String): List<WorkspaceSummary> {
if (body.isBlank()) {
return emptyList()
}
val trimmed = body.trim()
val array = if (trimmed.startsWith("[")) {
JSONArray(trimmed)
} else {
val root = JSONObject(trimmed)
listOf("workspaces", "items", "data")
.firstNotNullOfOrNull { root.optJSONArray(it) }
?: JSONArray()
}
val workspaces = mutableListOf<WorkspaceSummary>()
for (index in 0 until array.length()) {
val item = array.optJSONObject(index) ?: continue
val workspace = item.optJSONObject("workspace") ?: item
val id = workspace.opt("id")?.toString().orEmpty().ifBlank {
workspace.optString("publicId")
.ifBlank { workspace.optString("public_id") }
}
if (id.isBlank()) {
continue
}
val name = workspace.optString("name")
.ifBlank { workspace.optString("title") }
.ifBlank { workspace.optString("slug") }
.ifBlank { "Workspace" }
workspaces += WorkspaceSummary(id = id, name = name)
}
return workspaces
}
private fun serverMessage(body: String, code: Int): String {
if (body.isBlank()) {
return "Server error: $code"

View File

@@ -13,12 +13,23 @@ class SessionPreferences(context: Context) : SessionStore {
preferences.edit().putString(KEY_BASE_URL, url).apply()
}
override fun getWorkspaceId(): String? = preferences.getString(KEY_WORKSPACE_ID, null)
override fun saveWorkspaceId(workspaceId: String) {
preferences.edit().putString(KEY_WORKSPACE_ID, workspaceId).apply()
}
override fun clearBaseUrl() {
preferences.edit().remove(KEY_BASE_URL).apply()
}
override fun clearWorkspaceId() {
preferences.edit().remove(KEY_WORKSPACE_ID).apply()
}
private companion object {
private const val PREFS_NAME = "kanbn_session"
private const val KEY_BASE_URL = "base_url"
private const val KEY_WORKSPACE_ID = "workspace_id"
}
}

View File

@@ -3,5 +3,8 @@ package space.hackenslacker.kanbn4droid.app.auth
interface SessionStore {
fun getBaseUrl(): String?
fun saveBaseUrl(url: String)
fun getWorkspaceId(): String?
fun saveWorkspaceId(workspaceId: String)
fun clearBaseUrl()
fun clearWorkspaceId()
}

View File

@@ -10,6 +10,11 @@ data class BoardTemplate(
val name: String,
)
data class WorkspaceSummary(
val id: String,
val name: String,
)
sealed interface BoardsApiResult<out T> {
data class Success<T>(val value: T) : BoardsApiResult<T>
data class Failure(val message: String) : BoardsApiResult<Nothing>

View File

@@ -14,47 +14,96 @@ class BoardsRepository(
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
suspend fun listBoards(): BoardsApiResult<List<BoardSummary>> {
val session = session() ?: return BoardsApiResult.Failure("Missing session. Please sign in again.")
return apiClient.listBoards(session.baseUrl, session.apiKey)
val session = when (val sessionResult = session()) {
is BoardsApiResult.Success -> sessionResult.value
is BoardsApiResult.Failure -> return sessionResult
}
return apiClient.listBoards(
baseUrl = session.baseUrl,
apiKey = session.apiKey,
workspaceId = session.workspaceId,
)
}
suspend fun listTemplates(): BoardsApiResult<List<BoardTemplate>> {
val session = session() ?: return BoardsApiResult.Failure("Missing session. Please sign in again.")
return apiClient.listBoardTemplates(session.baseUrl, session.apiKey)
val session = when (val sessionResult = session()) {
is BoardsApiResult.Success -> sessionResult.value
is BoardsApiResult.Failure -> return sessionResult
}
return apiClient.listBoardTemplates(
baseUrl = session.baseUrl,
apiKey = session.apiKey,
workspaceId = session.workspaceId,
)
}
suspend fun createBoard(name: String, templateId: String?): BoardsApiResult<BoardSummary> {
val session = session() ?: return BoardsApiResult.Failure("Missing session. Please sign in again.")
val session = when (val sessionResult = session()) {
is BoardsApiResult.Success -> sessionResult.value
is BoardsApiResult.Failure -> return sessionResult
}
if (name.isBlank()) {
return BoardsApiResult.Failure("Board name is required")
}
return apiClient.createBoard(
baseUrl = session.baseUrl,
apiKey = session.apiKey,
workspaceId = session.workspaceId,
name = name.trim(),
templateId = templateId,
)
}
suspend fun deleteBoard(boardId: String): BoardsApiResult<Unit> {
val session = session() ?: return BoardsApiResult.Failure("Missing session. Please sign in again.")
val session = when (val sessionResult = session()) {
is BoardsApiResult.Success -> sessionResult.value
is BoardsApiResult.Failure -> return sessionResult
}
if (boardId.isBlank()) {
return BoardsApiResult.Failure("Board id is required")
}
return apiClient.deleteBoard(session.baseUrl, session.apiKey, boardId)
}
private suspend fun session(): SessionSnapshot? {
val baseUrl = sessionStore.getBaseUrl()?.takeIf { it.isNotBlank() } ?: return null
private suspend fun session(): BoardsApiResult<SessionSnapshot> {
val baseUrl = sessionStore.getBaseUrl()?.takeIf { it.isNotBlank() }
?: return BoardsApiResult.Failure("Missing session. Please sign in again.")
val apiKey = withContext(ioDispatcher) {
apiKeyStore.getApiKey(baseUrl)
}.getOrNull()?.takeIf { it.isNotBlank() } ?: return null
}.getOrNull()?.takeIf { it.isNotBlank() }
?: return BoardsApiResult.Failure("Missing session. Please sign in again.")
return SessionSnapshot(baseUrl = baseUrl, apiKey = apiKey)
val workspaceId = when (val workspaceResult = resolveWorkspaceId(baseUrl = baseUrl, apiKey = apiKey)) {
is BoardsApiResult.Success -> workspaceResult.value
is BoardsApiResult.Failure -> return workspaceResult
}
return BoardsApiResult.Success(
SessionSnapshot(baseUrl = baseUrl, apiKey = apiKey, workspaceId = workspaceId),
)
}
private suspend fun resolveWorkspaceId(baseUrl: String, apiKey: String): BoardsApiResult<String> {
val storedWorkspaceId = sessionStore.getWorkspaceId()?.takeIf { it.isNotBlank() }
if (storedWorkspaceId != null) {
return BoardsApiResult.Success(storedWorkspaceId)
}
return when (val workspacesResult = apiClient.listWorkspaces(baseUrl, apiKey)) {
is BoardsApiResult.Success -> {
val first = workspacesResult.value.firstOrNull()?.id
?: return BoardsApiResult.Failure("No workspaces available for this account.")
sessionStore.saveWorkspaceId(first)
BoardsApiResult.Success(first)
}
is BoardsApiResult.Failure -> workspacesResult
}
}
private data class SessionSnapshot(
val baseUrl: String,
val apiKey: String,
val workspaceId: String,
)
}

View File

@@ -30,6 +30,7 @@ class BoardsRepositoryTest {
listBoardsResult = BoardsApiResult.Success(
listOf(BoardSummary("1", "Alpha"), BoardSummary("2", "Beta")),
)
workspacesResult = BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main")))
}
val repository = BoardsRepository(
@@ -44,12 +45,74 @@ class BoardsRepositoryTest {
val boards = (result as BoardsApiResult.Success).value
assertEquals(2, boards.size)
assertEquals("Alpha", boards[0].title)
assertEquals("ws-1", fakeApi.lastWorkspaceId)
}
@Test
fun listBoardsUsesStoredWorkspaceWhenAvailable() = runTest {
val fakeApi = FakeBoardsApiClient().apply {
listBoardsResult = BoardsApiResult.Success(listOf(BoardSummary("1", "Alpha")))
}
val repository = BoardsRepository(
sessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = "ws-stored"),
apiKeyStore = InMemoryApiKeyStore("api"),
apiClient = fakeApi,
)
repository.listBoards()
assertEquals("ws-stored", fakeApi.lastWorkspaceId)
assertEquals(0, fakeApi.listWorkspacesCalls)
}
@Test
fun listBoardsResolvesFirstWorkspaceWhenMissingInSession() = runTest {
val fakeApi = FakeBoardsApiClient().apply {
workspacesResult = BoardsApiResult.Success(
listOf(WorkspaceSummary("ws-first", "Alpha"), WorkspaceSummary("ws-second", "Beta")),
)
listBoardsResult = BoardsApiResult.Success(listOf(BoardSummary("1", "Alpha")))
}
val sessionStore = InMemorySessionStore("https://kan.bn/")
val repository = BoardsRepository(
sessionStore = sessionStore,
apiKeyStore = InMemoryApiKeyStore("api"),
apiClient = fakeApi,
)
val result = repository.listBoards()
assertTrue(result is BoardsApiResult.Success)
assertEquals("ws-first", sessionStore.getWorkspaceId())
assertEquals("ws-first", fakeApi.lastWorkspaceId)
assertEquals(1, fakeApi.listWorkspacesCalls)
}
@Test
fun listBoardsFailsWhenNoWorkspacesReturned() = runTest {
val fakeApi = FakeBoardsApiClient().apply {
workspacesResult = BoardsApiResult.Success(emptyList())
}
val repository = BoardsRepository(
sessionStore = InMemorySessionStore("https://kan.bn/"),
apiKeyStore = InMemoryApiKeyStore("api"),
apiClient = fakeApi,
)
val result = repository.listBoards()
assertTrue(result is BoardsApiResult.Failure)
assertEquals("No workspaces available for this account.", (result as BoardsApiResult.Failure).message)
}
@Test
fun createBoardTrimsNameAndPassesTemplateId() = runTest {
val fakeApi = FakeBoardsApiClient().apply {
createBoardResult = BoardsApiResult.Success(BoardSummary("33", "Roadmap"))
workspacesResult = BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main")))
}
val repository = BoardsRepository(
@@ -63,12 +126,14 @@ class BoardsRepositoryTest {
assertTrue(result is BoardsApiResult.Success)
assertEquals("Roadmap", fakeApi.lastCreateName)
assertEquals("tpl-1", fakeApi.lastCreateTemplateId)
assertEquals("ws-1", fakeApi.lastCreateWorkspaceId)
}
@Test
fun deleteBoardPassesBoardIdToApi() = runTest {
val fakeApi = FakeBoardsApiClient().apply {
deleteBoardResult = BoardsApiResult.Success(Unit)
workspacesResult = BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main")))
}
val repository = BoardsRepository(
@@ -83,15 +148,28 @@ class BoardsRepositoryTest {
assertEquals("42", fakeApi.lastDeletedId)
}
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? = workspaceId
override fun saveWorkspaceId(workspaceId: String) {
this.workspaceId = workspaceId
}
override fun clearBaseUrl() {
baseUrl = null
}
override fun clearWorkspaceId() {
workspaceId = null
}
}
private class InMemoryApiKeyStore(private var apiKey: String?) : ApiKeyStore {
@@ -111,32 +189,54 @@ class BoardsRepositoryTest {
private class FakeBoardsApiClient : KanbnApiClient {
var listBoardsResult: BoardsApiResult<List<BoardSummary>> = BoardsApiResult.Success(emptyList())
var listTemplatesResult: BoardsApiResult<List<BoardTemplate>> = BoardsApiResult.Success(emptyList())
var workspacesResult: BoardsApiResult<List<WorkspaceSummary>> =
BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main")))
var createBoardResult: BoardsApiResult<BoardSummary> = BoardsApiResult.Success(BoardSummary("new", "New"))
var deleteBoardResult: BoardsApiResult<Unit> = BoardsApiResult.Success(Unit)
var listWorkspacesCalls: Int = 0
var lastWorkspaceId: String? = null
var lastCreateName: String? = null
var lastCreateTemplateId: String? = null
var lastCreateWorkspaceId: String? = null
var lastDeletedId: String? = null
override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = AuthResult.Success
override suspend fun listBoards(baseUrl: String, apiKey: String): BoardsApiResult<List<BoardSummary>> {
override suspend fun listWorkspaces(
baseUrl: String,
apiKey: String,
): BoardsApiResult<List<WorkspaceSummary>> {
listWorkspacesCalls += 1
return workspacesResult
}
override suspend fun listBoards(
baseUrl: String,
apiKey: String,
workspaceId: String,
): BoardsApiResult<List<BoardSummary>> {
lastWorkspaceId = workspaceId
return listBoardsResult
}
override suspend fun listBoardTemplates(
baseUrl: String,
apiKey: String,
workspaceId: String,
): BoardsApiResult<List<BoardTemplate>> {
lastWorkspaceId = workspaceId
return listTemplatesResult
}
override suspend fun createBoard(
baseUrl: String,
apiKey: String,
workspaceId: String,
name: String,
templateId: String?,
): BoardsApiResult<BoardSummary> {
lastCreateWorkspaceId = workspaceId
lastCreateName = name
lastCreateTemplateId = templateId
return createBoardResult

View File

@@ -127,9 +127,17 @@ class BoardsViewModelTest {
baseUrl = url
}
override fun getWorkspaceId(): String? = "ws-1"
override fun saveWorkspaceId(workspaceId: String) {
}
override fun clearBaseUrl() {
baseUrl = null
}
override fun clearWorkspaceId() {
}
}
private class InMemoryApiKeyStore(private var apiKey: String?) : ApiKeyStore {
@@ -156,17 +164,26 @@ class BoardsViewModelTest {
override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = AuthResult.Success
override suspend fun listBoards(baseUrl: String, apiKey: String): BoardsApiResult<List<BoardSummary>> {
override suspend fun listBoards(
baseUrl: String,
apiKey: String,
workspaceId: String,
): BoardsApiResult<List<BoardSummary>> {
return listBoardsResult
}
override suspend fun listBoardTemplates(baseUrl: String, apiKey: String): BoardsApiResult<List<BoardTemplate>> {
override suspend fun listBoardTemplates(
baseUrl: String,
apiKey: String,
workspaceId: String,
): BoardsApiResult<List<BoardTemplate>> {
return listTemplatesResult
}
override suspend fun createBoard(
baseUrl: String,
apiKey: String,
workspaceId: String,
name: String,
templateId: String?,
): BoardsApiResult<BoardSummary> {