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

@@ -50,6 +50,7 @@ Kanbn4Droid is an unofficial app to connect to and manipulate data stored in sel
- The API key is stored in app preferences together with the base URL. - The API key is stored in app preferences together with the base URL.
- No migration is performed from prior Credential Manager storage, so users must re-enter their API key one time after upgrading. - No migration is performed from prior Credential Manager storage, so users must re-enter their API key one time after upgrading.
- On success, the view stores the URL and API key pair in preferences and moves over to the boards view. - On success, the view stores the URL and API key pair in preferences and moves over to the boards view.
- On successful manual sign-in, the stored workspace id is cleared so the boards flow can resolve a fresh default workspace for the account.
- If there is a URL and API Key pair stored, the view tries to authenticate the user through the API automatically and proceeds to the boards view instantly without showing the login screen if successful. - If there is a URL and API Key pair stored, the view tries to authenticate the user through the API automatically and proceeds to the boards view instantly without showing the login screen if successful.
- If startup authentication fails due to invalid credentials then the stored API key is invalidated; transient connectivity/server failures keep the stored key and return to login. - If startup authentication fails due to invalid credentials then the stored API key is invalidated; transient connectivity/server failures keep the stored key and return to login.
- Current status: implemented in `MainActivity` with XML views and navigation into `BoardsActivity`. - Current status: implemented in `MainActivity` with XML views and navigation into `BoardsActivity`.
@@ -71,7 +72,7 @@ Kanbn4Droid is an unofficial app to connect to and manipulate data stored in sel
- Long-pressing an existing board shows a modal dialog asking the user if they want to delete the board, with buttons for "Cancel" and "Delete". - Long-pressing an existing board shows a modal dialog asking the user if they want to delete the board, with buttons for "Cancel" and "Delete".
- Pressing "Delete" in the modal dialog MUST show a second confirmation modal asking if the user is sure, with buttons for "Cancel" and "I'm sure". - Pressing "Delete" in the modal dialog MUST show a second confirmation modal asking if the user is sure, with buttons for "Cancel" and "I'm sure".
- Only on pressing "I'm sure" in the second confirmation modal dialog should a board delete request be sent to the API. - Only on pressing "I'm sure" in the second confirmation modal dialog should a board delete request be sent to the API.
- Current status: implemented in `BoardsActivity` using XML Views, `RecyclerView`, `SwipeRefreshLayout`, and a `BoardsViewModel`/`BoardsRepository` flow. The screen includes auto-refresh on entry, pull-to-refresh, create board dialog with optional template selector, and two-step board delete confirmation. Board taps navigate to `BoardDetailPlaceholderActivity` while full board detail is still pending. - Current status: implemented in `BoardsActivity` using XML Views, `RecyclerView`, `SwipeRefreshLayout`, and a `BoardsViewModel`/`BoardsRepository` flow. The screen includes auto-refresh on entry, pull-to-refresh, create board dialog with optional template selector, and two-step board delete confirmation. Board taps navigate to `BoardDetailPlaceholderActivity` while full board detail is still pending. API calls are workspace-scoped; when no workspace is stored the app resolves workspaces through the API, stores the first workspace id as default, and uses it for board listing, template listing (`type=template`), and board creation.
**Board detail view** **Board detail view**

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

View File

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

View File

@@ -159,6 +159,7 @@ class MainActivity : AppCompatActivity() {
} }
if (saveKeyResult.isSuccess) { if (saveKeyResult.isSuccess) {
sessionStore.saveBaseUrl(normalizedBaseUrl) sessionStore.saveBaseUrl(normalizedBaseUrl)
sessionStore.clearWorkspaceId()
openBoards() openBoards()
} else { } else {
loginProgress.visibility = View.GONE 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.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.WorkspaceSummary
interface KanbnApiClient { interface KanbnApiClient {
suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult 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.") 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.") return BoardsApiResult.Failure("Board templates listing is not implemented.")
} }
suspend fun createBoard( suspend fun createBoard(
baseUrl: String, baseUrl: String,
apiKey: String, apiKey: String,
workspaceId: String,
name: String, name: String,
templateId: String?, templateId: String?,
): BoardsApiResult<BoardSummary> { ): BoardsApiResult<BoardSummary> {
@@ -38,6 +52,23 @@ interface KanbnApiClient {
class HttpKanbnApiClient : 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 { override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
val endpoint = "${baseUrl.trimEnd('/')}/api/v1/health" 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) { return withContext(Dispatchers.IO) {
request( request(
baseUrl = baseUrl, baseUrl = baseUrl,
path = "/api/v1/boards", path = "/api/v1/workspaces/$workspaceId/boards",
method = "GET", method = "GET",
apiKey = apiKey, apiKey = apiKey,
) { code, body -> ) { code, body ->
@@ -87,11 +122,12 @@ class HttpKanbnApiClient : KanbnApiClient {
override suspend fun listBoardTemplates( override suspend fun listBoardTemplates(
baseUrl: String, baseUrl: String,
apiKey: String, apiKey: String,
workspaceId: String,
): BoardsApiResult<List<BoardTemplate>> { ): BoardsApiResult<List<BoardTemplate>> {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
request( request(
baseUrl = baseUrl, baseUrl = baseUrl,
path = "/api/v1/board-templates", path = "/api/v1/workspaces/$workspaceId/boards?type=template",
method = "GET", method = "GET",
apiKey = apiKey, apiKey = apiKey,
) { code, body -> ) { code, body ->
@@ -107,18 +143,22 @@ class HttpKanbnApiClient : KanbnApiClient {
override suspend fun createBoard( override suspend fun createBoard(
baseUrl: String, baseUrl: String,
apiKey: String, apiKey: String,
workspaceId: String,
name: String, name: String,
templateId: String?, templateId: String?,
): BoardsApiResult<BoardSummary> { ): BoardsApiResult<BoardSummary> {
return withContext(Dispatchers.IO) { 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()) { if (!templateId.isNullOrBlank()) {
payload.put("template_id", templateId) payload.put("sourceBoardPublicId", templateId)
} }
request( request(
baseUrl = baseUrl, baseUrl = baseUrl,
path = "/api/v1/boards", path = "/api/v1/workspaces/$workspaceId/boards",
method = "POST", method = "POST",
apiKey = apiKey, apiKey = apiKey,
body = payload.toString(), body = payload.toString(),
@@ -222,9 +262,14 @@ class HttpKanbnApiClient : KanbnApiClient {
val boards = mutableListOf<BoardSummary>() val boards = mutableListOf<BoardSummary>()
for (index in 0 until array.length()) { for (index in 0 until array.length()) {
val item = array.optJSONObject(index) ?: continue 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 { val title = item.optString("title").ifBlank {
item.optString("name").ifBlank { "Board" } item.optString("name").ifBlank {
item.optString("slug").ifBlank { "Board" }
}
} }
if (id.isNotBlank()) { if (id.isNotBlank()) {
boards += BoardSummary(id = id, title = title) boards += BoardSummary(id = id, title = title)
@@ -239,7 +284,11 @@ class HttpKanbnApiClient : KanbnApiClient {
} }
val root = JSONObject(body) 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 } } val title = root.optString("title").ifBlank { root.optString("name").ifBlank { fallbackName } }
return BoardSummary(id = if (id.isBlank()) "new" else id, title = title) return BoardSummary(id = if (id.isBlank()) "new" else id, title = title)
} }
@@ -262,7 +311,10 @@ class HttpKanbnApiClient : KanbnApiClient {
val templates = mutableListOf<BoardTemplate>() val templates = mutableListOf<BoardTemplate>()
for (index in 0 until array.length()) { for (index in 0 until array.length()) {
val item = array.optJSONObject(index) ?: continue 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 { val name = item.optString("name").ifBlank {
item.optString("title").ifBlank { "Template" } item.optString("title").ifBlank { "Template" }
} }
@@ -273,6 +325,42 @@ class HttpKanbnApiClient : KanbnApiClient {
return templates 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 { private fun serverMessage(body: String, code: Int): String {
if (body.isBlank()) { if (body.isBlank()) {
return "Server error: $code" return "Server error: $code"

View File

@@ -13,12 +13,23 @@ class SessionPreferences(context: Context) : SessionStore {
preferences.edit().putString(KEY_BASE_URL, url).apply() 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() { override fun clearBaseUrl() {
preferences.edit().remove(KEY_BASE_URL).apply() preferences.edit().remove(KEY_BASE_URL).apply()
} }
override fun clearWorkspaceId() {
preferences.edit().remove(KEY_WORKSPACE_ID).apply()
}
private companion object { private companion object {
private const val PREFS_NAME = "kanbn_session" private const val PREFS_NAME = "kanbn_session"
private const val KEY_BASE_URL = "base_url" 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 { interface SessionStore {
fun getBaseUrl(): String? fun getBaseUrl(): String?
fun saveBaseUrl(url: String) fun saveBaseUrl(url: String)
fun getWorkspaceId(): String?
fun saveWorkspaceId(workspaceId: String)
fun clearBaseUrl() fun clearBaseUrl()
fun clearWorkspaceId()
} }

View File

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

View File

@@ -14,47 +14,96 @@ class BoardsRepository(
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) { ) {
suspend fun listBoards(): BoardsApiResult<List<BoardSummary>> { suspend fun listBoards(): BoardsApiResult<List<BoardSummary>> {
val session = session() ?: return BoardsApiResult.Failure("Missing session. Please sign in again.") val session = when (val sessionResult = session()) {
return apiClient.listBoards(session.baseUrl, session.apiKey) 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>> { suspend fun listTemplates(): BoardsApiResult<List<BoardTemplate>> {
val session = session() ?: return BoardsApiResult.Failure("Missing session. Please sign in again.") val session = when (val sessionResult = session()) {
return apiClient.listBoardTemplates(session.baseUrl, session.apiKey) 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> { 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()) { if (name.isBlank()) {
return BoardsApiResult.Failure("Board name is required") return BoardsApiResult.Failure("Board name is required")
} }
return apiClient.createBoard( return apiClient.createBoard(
baseUrl = session.baseUrl, baseUrl = session.baseUrl,
apiKey = session.apiKey, apiKey = session.apiKey,
workspaceId = session.workspaceId,
name = name.trim(), name = name.trim(),
templateId = templateId, templateId = templateId,
) )
} }
suspend fun deleteBoard(boardId: String): BoardsApiResult<Unit> { 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()) { if (boardId.isBlank()) {
return BoardsApiResult.Failure("Board id is required") return BoardsApiResult.Failure("Board id is required")
} }
return apiClient.deleteBoard(session.baseUrl, session.apiKey, boardId) return apiClient.deleteBoard(session.baseUrl, session.apiKey, boardId)
} }
private suspend fun session(): SessionSnapshot? { private suspend fun session(): BoardsApiResult<SessionSnapshot> {
val baseUrl = sessionStore.getBaseUrl()?.takeIf { it.isNotBlank() } ?: return null val baseUrl = sessionStore.getBaseUrl()?.takeIf { it.isNotBlank() }
?: return BoardsApiResult.Failure("Missing session. Please sign in again.")
val apiKey = withContext(ioDispatcher) { val apiKey = withContext(ioDispatcher) {
apiKeyStore.getApiKey(baseUrl) 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( private data class SessionSnapshot(
val baseUrl: String, val baseUrl: String,
val apiKey: String, val apiKey: String,
val workspaceId: String,
) )
} }

View File

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

View File

@@ -127,9 +127,17 @@ class BoardsViewModelTest {
baseUrl = url baseUrl = url
} }
override fun getWorkspaceId(): String? = "ws-1"
override fun saveWorkspaceId(workspaceId: String) {
}
override fun clearBaseUrl() { override fun clearBaseUrl() {
baseUrl = null baseUrl = null
} }
override fun clearWorkspaceId() {
}
} }
private class InMemoryApiKeyStore(private var apiKey: String?) : ApiKeyStore { 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 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 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 return listTemplatesResult
} }
override suspend fun createBoard( override suspend fun createBoard(
baseUrl: String, baseUrl: String,
apiKey: String, apiKey: String,
workspaceId: String,
name: String, name: String,
templateId: String?, templateId: String?,
): BoardsApiResult<BoardSummary> { ): BoardsApiResult<BoardSummary> {