diff --git a/AGENTS.md b/AGENTS.md index 8fb330f..604415f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. - 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 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 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`. @@ -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". - 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. -- 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** diff --git a/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardsFlowTest.kt b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardsFlowTest.kt index daeaa62..fbd6c8e 100644 --- a/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardsFlowTest.kt +++ b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardsFlowTest.kt @@ -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> { + override suspend fun listWorkspaces( + baseUrl: String, + apiKey: String, + ): BoardsApiResult> { + return BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main"))) + } + + override suspend fun listBoards( + baseUrl: String, + apiKey: String, + workspaceId: String, + ): BoardsApiResult> { return BoardsApiResult.Success(boards.toList()) } override suspend fun listBoardTemplates( baseUrl: String, apiKey: String, + workspaceId: String, ): BoardsApiResult> { 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 { diff --git a/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/LoginFlowTest.kt b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/LoginFlowTest.kt index 2a1e866..67350a9 100644 --- a/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/LoginFlowTest.kt +++ b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/LoginFlowTest.kt @@ -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( diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/MainActivity.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/MainActivity.kt index 03389f7..26a0c84 100644 --- a/app/src/main/java/space/hackenslacker/kanbn4droid/app/MainActivity.kt +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/MainActivity.kt @@ -159,6 +159,7 @@ class MainActivity : AppCompatActivity() { } if (saveKeyResult.isSuccess) { sessionStore.saveBaseUrl(normalizedBaseUrl) + sessionStore.clearWorkspaceId() openBoards() } else { loginProgress.visibility = View.GONE diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/KanbnApiClient.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/KanbnApiClient.kt index 07b9599..b673917 100644 --- a/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/KanbnApiClient.kt +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/KanbnApiClient.kt @@ -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> { + suspend fun listWorkspaces(baseUrl: String, apiKey: String): BoardsApiResult> { + return BoardsApiResult.Failure("Workspace listing is not implemented.") + } + + suspend fun listBoards( + baseUrl: String, + apiKey: String, + workspaceId: String, + ): BoardsApiResult> { return BoardsApiResult.Failure("Boards listing is not implemented.") } - suspend fun listBoardTemplates(baseUrl: String, apiKey: String): BoardsApiResult> { + suspend fun listBoardTemplates( + baseUrl: String, + apiKey: String, + workspaceId: String, + ): BoardsApiResult> { return BoardsApiResult.Failure("Board templates listing is not implemented.") } suspend fun createBoard( baseUrl: String, apiKey: String, + workspaceId: String, name: String, templateId: String?, ): BoardsApiResult { @@ -38,6 +52,23 @@ interface KanbnApiClient { class HttpKanbnApiClient : KanbnApiClient { + override suspend fun listWorkspaces(baseUrl: String, apiKey: String): BoardsApiResult> { + 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> { + override suspend fun listBoards( + baseUrl: String, + apiKey: String, + workspaceId: String, + ): BoardsApiResult> { 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> { 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 { 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() 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() 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 { + 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() + 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" diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/SessionPreferences.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/SessionPreferences.kt index 9b24e66..a590c2d 100644 --- a/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/SessionPreferences.kt +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/SessionPreferences.kt @@ -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" } } diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/SessionStore.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/SessionStore.kt index de5a57c..19316ba 100644 --- a/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/SessionStore.kt +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/SessionStore.kt @@ -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() } diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsModels.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsModels.kt index 971b068..55c8351 100644 --- a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsModels.kt +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsModels.kt @@ -10,6 +10,11 @@ data class BoardTemplate( val name: String, ) +data class WorkspaceSummary( + val id: String, + val name: String, +) + sealed interface BoardsApiResult { data class Success(val value: T) : BoardsApiResult data class Failure(val message: String) : BoardsApiResult diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsRepository.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsRepository.kt index 0e92d84..4fe1d47 100644 --- a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsRepository.kt +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsRepository.kt @@ -14,47 +14,96 @@ class BoardsRepository( private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, ) { suspend fun listBoards(): BoardsApiResult> { - 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> { - 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 { - 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 { - 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 { + 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 { + 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, ) } diff --git a/app/src/test/java/space/hackenslacker/kanbn4droid/app/boards/BoardsRepositoryTest.kt b/app/src/test/java/space/hackenslacker/kanbn4droid/app/boards/BoardsRepositoryTest.kt index 588a438..2aabe86 100644 --- a/app/src/test/java/space/hackenslacker/kanbn4droid/app/boards/BoardsRepositoryTest.kt +++ b/app/src/test/java/space/hackenslacker/kanbn4droid/app/boards/BoardsRepositoryTest.kt @@ -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> = BoardsApiResult.Success(emptyList()) var listTemplatesResult: BoardsApiResult> = BoardsApiResult.Success(emptyList()) + var workspacesResult: BoardsApiResult> = + BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main"))) var createBoardResult: BoardsApiResult = BoardsApiResult.Success(BoardSummary("new", "New")) var deleteBoardResult: BoardsApiResult = 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> { + override suspend fun listWorkspaces( + baseUrl: String, + apiKey: String, + ): BoardsApiResult> { + listWorkspacesCalls += 1 + return workspacesResult + } + + override suspend fun listBoards( + baseUrl: String, + apiKey: String, + workspaceId: String, + ): BoardsApiResult> { + lastWorkspaceId = workspaceId return listBoardsResult } override suspend fun listBoardTemplates( baseUrl: String, apiKey: String, + workspaceId: String, ): BoardsApiResult> { + lastWorkspaceId = workspaceId return listTemplatesResult } override suspend fun createBoard( baseUrl: String, apiKey: String, + workspaceId: String, name: String, templateId: String?, ): BoardsApiResult { + lastCreateWorkspaceId = workspaceId lastCreateName = name lastCreateTemplateId = templateId return createBoardResult diff --git a/app/src/test/java/space/hackenslacker/kanbn4droid/app/boards/BoardsViewModelTest.kt b/app/src/test/java/space/hackenslacker/kanbn4droid/app/boards/BoardsViewModelTest.kt index 96f2fbb..a5d901e 100644 --- a/app/src/test/java/space/hackenslacker/kanbn4droid/app/boards/BoardsViewModelTest.kt +++ b/app/src/test/java/space/hackenslacker/kanbn4droid/app/boards/BoardsViewModelTest.kt @@ -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> { + override suspend fun listBoards( + baseUrl: String, + apiKey: String, + workspaceId: String, + ): BoardsApiResult> { return listBoardsResult } - override suspend fun listBoardTemplates(baseUrl: String, apiKey: String): BoardsApiResult> { + override suspend fun listBoardTemplates( + baseUrl: String, + apiKey: String, + workspaceId: String, + ): BoardsApiResult> { return listTemplatesResult } override suspend fun createBoard( baseUrl: String, apiKey: String, + workspaceId: String, name: String, templateId: String?, ): BoardsApiResult {