feat: add board detail and card/list mutation API methods
This commit is contained in:
@@ -23,6 +23,7 @@ Kanbn4Droid is an unofficial app to connect to and manipulate data stored in sel
|
|||||||
- Compile/target SDK: API 35.
|
- Compile/target SDK: API 35.
|
||||||
- Baseline tests:
|
- Baseline tests:
|
||||||
- JVM unit tests for auth URL normalization and auth error mapping in `app/src/test/`.
|
- JVM unit tests for auth URL normalization and auth error mapping in `app/src/test/`.
|
||||||
|
- JVM unit tests for board detail parsing and board/list/card API request mapping in `app/src/test/space/hackenslacker/kanbn4droid/app/auth/HttpKanbnApiClientBoardDetailParsingTest.kt`.
|
||||||
- JVM unit tests for boards repository and boards view model in `app/src/test/space/hackenslacker/kanbn4droid/app/boards/`.
|
- JVM unit tests for boards repository and boards view model in `app/src/test/space/hackenslacker/kanbn4droid/app/boards/`.
|
||||||
- Instrumentation tests for login and boards flows in `app/src/androidTest/`.
|
- Instrumentation tests for login and boards flows in `app/src/androidTest/`.
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
|
|||||||
import space.hackenslacker.kanbn4droid.app.auth.AuthResult
|
import space.hackenslacker.kanbn4droid.app.auth.AuthResult
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
|
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
|
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
|
||||||
|
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetail
|
||||||
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
|
||||||
@@ -199,5 +200,31 @@ class BoardsFlowTest {
|
|||||||
boards.removeAll { it.id == boardId }
|
boards.removeAll { it.id == boardId }
|
||||||
return BoardsApiResult.Success(Unit)
|
return BoardsApiResult.Success(Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getBoardDetail(baseUrl: String, apiKey: String, boardId: String): BoardsApiResult<BoardDetail> {
|
||||||
|
return BoardsApiResult.Failure("Not used in this test")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun renameList(
|
||||||
|
baseUrl: String,
|
||||||
|
apiKey: String,
|
||||||
|
listId: String,
|
||||||
|
newTitle: String,
|
||||||
|
): BoardsApiResult<Unit> {
|
||||||
|
return BoardsApiResult.Failure("Not used in this test")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun moveCard(
|
||||||
|
baseUrl: String,
|
||||||
|
apiKey: String,
|
||||||
|
cardId: String,
|
||||||
|
targetListId: String,
|
||||||
|
): BoardsApiResult<Unit> {
|
||||||
|
return BoardsApiResult.Failure("Not used in this test")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteCard(baseUrl: String, apiKey: String, cardId: String): BoardsApiResult<Unit> {
|
||||||
|
return BoardsApiResult.Failure("Not used in this test")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
|
|||||||
import space.hackenslacker.kanbn4droid.app.auth.AuthResult
|
import space.hackenslacker.kanbn4droid.app.auth.AuthResult
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
|
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
|
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
|
||||||
|
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetail
|
||||||
|
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
class LoginFlowTest {
|
class LoginFlowTest {
|
||||||
@@ -200,5 +202,31 @@ class LoginFlowTest {
|
|||||||
private val result: AuthResult,
|
private val result: AuthResult,
|
||||||
) : KanbnApiClient {
|
) : KanbnApiClient {
|
||||||
override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = result
|
override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = result
|
||||||
|
|
||||||
|
override suspend fun getBoardDetail(baseUrl: String, apiKey: String, boardId: String): BoardsApiResult<BoardDetail> {
|
||||||
|
return BoardsApiResult.Failure("Not used in this test")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun renameList(
|
||||||
|
baseUrl: String,
|
||||||
|
apiKey: String,
|
||||||
|
listId: String,
|
||||||
|
newTitle: String,
|
||||||
|
): BoardsApiResult<Unit> {
|
||||||
|
return BoardsApiResult.Failure("Not used in this test")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun moveCard(
|
||||||
|
baseUrl: String,
|
||||||
|
apiKey: String,
|
||||||
|
cardId: String,
|
||||||
|
targetListId: String,
|
||||||
|
): BoardsApiResult<Unit> {
|
||||||
|
return BoardsApiResult.Failure("Not used in this test")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteCard(baseUrl: String, apiKey: String, cardId: String): BoardsApiResult<Unit> {
|
||||||
|
return BoardsApiResult.Failure("Not used in this test")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,15 @@ package space.hackenslacker.kanbn4droid.app.auth
|
|||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
|
import java.time.Instant
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardCardSummary
|
||||||
|
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetail
|
||||||
|
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardListDetail
|
||||||
|
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardTagSummary
|
||||||
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
|
||||||
@@ -48,6 +53,22 @@ interface KanbnApiClient {
|
|||||||
suspend fun deleteBoard(baseUrl: String, apiKey: String, boardId: String): BoardsApiResult<Unit> {
|
suspend fun deleteBoard(baseUrl: String, apiKey: String, boardId: String): BoardsApiResult<Unit> {
|
||||||
return BoardsApiResult.Failure("Board deletion is not implemented.")
|
return BoardsApiResult.Failure("Board deletion is not implemented.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun getBoardDetail(baseUrl: String, apiKey: String, boardId: String): BoardsApiResult<BoardDetail> {
|
||||||
|
return BoardsApiResult.Failure("Board detail is not implemented.")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun renameList(baseUrl: String, apiKey: String, listId: String, newTitle: String): BoardsApiResult<Unit> {
|
||||||
|
return BoardsApiResult.Failure("List rename is not implemented.")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun moveCard(baseUrl: String, apiKey: String, cardId: String, targetListId: String): BoardsApiResult<Unit> {
|
||||||
|
return BoardsApiResult.Failure("Card move is not implemented.")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteCard(baseUrl: String, apiKey: String, cardId: String): BoardsApiResult<Unit> {
|
||||||
|
return BoardsApiResult.Failure("Card deletion is not implemented.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class HttpKanbnApiClient : KanbnApiClient {
|
class HttpKanbnApiClient : KanbnApiClient {
|
||||||
@@ -193,6 +214,94 @@ class HttpKanbnApiClient : KanbnApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getBoardDetail(
|
||||||
|
baseUrl: String,
|
||||||
|
apiKey: String,
|
||||||
|
boardId: String,
|
||||||
|
): BoardsApiResult<BoardDetail> {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
request(
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
path = "/api/v1/boards/$boardId",
|
||||||
|
method = "GET",
|
||||||
|
apiKey = apiKey,
|
||||||
|
) { code, body ->
|
||||||
|
if (code in 200..299) {
|
||||||
|
BoardsApiResult.Success(parseBoardDetail(body, boardId))
|
||||||
|
} else {
|
||||||
|
BoardsApiResult.Failure(serverMessage(body, code))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun renameList(
|
||||||
|
baseUrl: String,
|
||||||
|
apiKey: String,
|
||||||
|
listId: String,
|
||||||
|
newTitle: String,
|
||||||
|
): BoardsApiResult<Unit> {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
request(
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
path = "/api/v1/lists/$listId",
|
||||||
|
method = "PATCH",
|
||||||
|
apiKey = apiKey,
|
||||||
|
body = "{\"name\":\"${jsonEscape(newTitle.trim())}\"}",
|
||||||
|
) { code, body ->
|
||||||
|
if (code in 200..299) {
|
||||||
|
BoardsApiResult.Success(Unit)
|
||||||
|
} else {
|
||||||
|
BoardsApiResult.Failure(serverMessage(body, code))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun moveCard(
|
||||||
|
baseUrl: String,
|
||||||
|
apiKey: String,
|
||||||
|
cardId: String,
|
||||||
|
targetListId: String,
|
||||||
|
): BoardsApiResult<Unit> {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
request(
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
path = "/api/v1/cards/$cardId",
|
||||||
|
method = "PATCH",
|
||||||
|
apiKey = apiKey,
|
||||||
|
body = "{\"listId\":\"${jsonEscape(targetListId)}\"}",
|
||||||
|
) { code, body ->
|
||||||
|
if (code in 200..299) {
|
||||||
|
BoardsApiResult.Success(Unit)
|
||||||
|
} else {
|
||||||
|
BoardsApiResult.Failure(serverMessage(body, code))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteCard(
|
||||||
|
baseUrl: String,
|
||||||
|
apiKey: String,
|
||||||
|
cardId: String,
|
||||||
|
): BoardsApiResult<Unit> {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
request(
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
path = "/api/v1/cards/$cardId",
|
||||||
|
method = "DELETE",
|
||||||
|
apiKey = apiKey,
|
||||||
|
) { code, body ->
|
||||||
|
if (code in 200..299) {
|
||||||
|
BoardsApiResult.Success(Unit)
|
||||||
|
} else {
|
||||||
|
BoardsApiResult.Failure(serverMessage(body, code))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun <T> request(
|
private fun <T> request(
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
path: String,
|
path: String,
|
||||||
@@ -203,7 +312,7 @@ class HttpKanbnApiClient : KanbnApiClient {
|
|||||||
): BoardsApiResult<T> {
|
): BoardsApiResult<T> {
|
||||||
val endpoint = "${baseUrl.trimEnd('/')}$path"
|
val endpoint = "${baseUrl.trimEnd('/')}$path"
|
||||||
val connection = (URL(endpoint).openConnection() as HttpURLConnection).apply {
|
val connection = (URL(endpoint).openConnection() as HttpURLConnection).apply {
|
||||||
requestMethod = method
|
configureRequestMethod(this, method)
|
||||||
connectTimeout = 10_000
|
connectTimeout = 10_000
|
||||||
readTimeout = 10_000
|
readTimeout = 10_000
|
||||||
setRequestProperty("x-api-key", apiKey)
|
setRequestProperty("x-api-key", apiKey)
|
||||||
@@ -237,6 +346,18 @@ class HttpKanbnApiClient : KanbnApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun configureRequestMethod(connection: HttpURLConnection, method: String) {
|
||||||
|
try {
|
||||||
|
connection.requestMethod = method
|
||||||
|
} catch (throwable: Throwable) {
|
||||||
|
if (method != "PATCH") {
|
||||||
|
throw throwable
|
||||||
|
}
|
||||||
|
connection.requestMethod = "POST"
|
||||||
|
connection.setRequestProperty("X-HTTP-Method-Override", "PATCH")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun readResponseBody(connection: HttpURLConnection, code: Int): String {
|
private fun readResponseBody(connection: HttpURLConnection, code: Int): String {
|
||||||
val stream = if (code in 200..299) connection.inputStream else connection.errorStream
|
val stream = if (code in 200..299) connection.inputStream else connection.errorStream
|
||||||
return stream?.bufferedReader()?.use { it.readText() }.orEmpty()
|
return stream?.bufferedReader()?.use { it.readText() }.orEmpty()
|
||||||
@@ -361,18 +482,295 @@ class HttpKanbnApiClient : KanbnApiClient {
|
|||||||
return workspaces
|
return workspaces
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun parseBoardDetail(body: String, fallbackId: String): BoardDetail {
|
||||||
|
val root = parseJsonObject(body)
|
||||||
|
?: return BoardDetail(id = fallbackId, title = "Board", lists = emptyList())
|
||||||
|
val data = root["data"] as? Map<*, *>
|
||||||
|
val board = (data?.get("board") as? Map<*, *>)
|
||||||
|
?: (root["board"] as? Map<*, *>)
|
||||||
|
?: root
|
||||||
|
|
||||||
|
val boardId = extractId(board).ifBlank { fallbackId }
|
||||||
|
val boardTitle = extractTitle(board, "Board")
|
||||||
|
val lists = parseLists(board)
|
||||||
|
|
||||||
|
return BoardDetail(id = boardId, title = boardTitle, lists = lists)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseLists(board: Map<*, *>): List<BoardListDetail> {
|
||||||
|
return extractObjectArray(board, "lists", "items", "data").mapNotNull { rawList ->
|
||||||
|
val id = extractId(rawList)
|
||||||
|
if (id.isBlank()) {
|
||||||
|
return@mapNotNull null
|
||||||
|
}
|
||||||
|
BoardListDetail(
|
||||||
|
id = id,
|
||||||
|
title = extractTitle(rawList, "List"),
|
||||||
|
cards = parseCards(rawList),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseCards(list: Map<*, *>): List<BoardCardSummary> {
|
||||||
|
return extractObjectArray(list, "cards", "items", "data").mapNotNull { rawCard ->
|
||||||
|
val id = extractId(rawCard)
|
||||||
|
if (id.isBlank()) {
|
||||||
|
return@mapNotNull null
|
||||||
|
}
|
||||||
|
BoardCardSummary(
|
||||||
|
id = id,
|
||||||
|
title = extractTitle(rawCard, "Card"),
|
||||||
|
tags = parseTags(rawCard),
|
||||||
|
dueAtEpochMillis = parseDueDate(rawCard),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseTags(card: Map<*, *>): List<BoardTagSummary> {
|
||||||
|
return extractObjectArray(card, "labels", "tags", "data").mapNotNull { rawTag ->
|
||||||
|
val id = extractId(rawTag)
|
||||||
|
if (id.isBlank()) {
|
||||||
|
return@mapNotNull null
|
||||||
|
}
|
||||||
|
BoardTagSummary(
|
||||||
|
id = id,
|
||||||
|
name = extractTitle(rawTag, "Tag"),
|
||||||
|
colorHex = extractString(rawTag, "colorHex", "color", "hex"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseDueDate(card: Map<*, *>): Long? {
|
||||||
|
val dueValue = firstPresent(card, "dueDate", "dueAt", "due_at", "due") ?: return null
|
||||||
|
return when (dueValue) {
|
||||||
|
is Number -> dueValue.toLong()
|
||||||
|
is String -> {
|
||||||
|
val trimmed = dueValue.trim()
|
||||||
|
if (trimmed.isBlank()) null else trimmed.toLongOrNull() ?: runCatching { Instant.parse(trimmed).toEpochMilli() }.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractObjectArray(source: Map<*, *>, vararg keys: String): List<Map<*, *>> {
|
||||||
|
val array = keys.firstNotNullOfOrNull { key -> source[key] as? List<*> } ?: return emptyList()
|
||||||
|
return array.mapNotNull { it as? Map<*, *> }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractId(source: Map<*, *>): String {
|
||||||
|
val directId = source["id"]?.toString().orEmpty()
|
||||||
|
if (directId.isNotBlank()) {
|
||||||
|
return directId
|
||||||
|
}
|
||||||
|
return extractString(source, "publicId", "public_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractTitle(source: Map<*, *>, fallback: String): String {
|
||||||
|
return extractString(source, "title", "name").ifBlank { fallback }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractString(source: Map<*, *>, vararg keys: String): String {
|
||||||
|
return keys.firstNotNullOfOrNull { key -> source[key]?.toString()?.takeIf { it.isNotBlank() } }.orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun firstPresent(source: Map<*, *>, vararg keys: String): Any? {
|
||||||
|
return keys.firstNotNullOfOrNull { key -> source[key] }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseJsonObject(body: String): Map<String, Any?>? {
|
||||||
|
val trimmed = body.trim()
|
||||||
|
if (trimmed.isBlank()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val parsed = runCatching { MiniJsonParser(trimmed).parseValue() }.getOrNull()
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
return parsed as? Map<String, Any?>
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun jsonEscape(value: String): String {
|
||||||
|
val builder = StringBuilder()
|
||||||
|
value.forEach { ch ->
|
||||||
|
when (ch) {
|
||||||
|
'\\' -> builder.append("\\\\")
|
||||||
|
'"' -> builder.append("\\\"")
|
||||||
|
'\b' -> builder.append("\\b")
|
||||||
|
'\u000C' -> builder.append("\\f")
|
||||||
|
'\n' -> builder.append("\\n")
|
||||||
|
'\r' -> builder.append("\\r")
|
||||||
|
'\t' -> builder.append("\\t")
|
||||||
|
else -> builder.append(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return builder.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private class MiniJsonParser(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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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"
|
||||||
}
|
}
|
||||||
|
|
||||||
return runCatching {
|
val root = parseJsonObject(body)
|
||||||
val root = JSONObject(body)
|
val message = root?.let { extractString(it, "message", "error", "cause", "detail") }.orEmpty()
|
||||||
listOf("message", "error", "cause", "detail")
|
return message.ifBlank { "Server error: $code" }
|
||||||
.firstNotNullOfOrNull { key -> root.optString(key).takeIf { it.isNotBlank() } }
|
|
||||||
?: "Server error: $code"
|
|
||||||
}.getOrElse {
|
|
||||||
"Server error: $code"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,357 @@
|
|||||||
|
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.time.Instant
|
||||||
|
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.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetail
|
||||||
|
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
|
||||||
|
|
||||||
|
class HttpKanbnApiClientBoardDetailParsingTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun getBoardDetailParsesWrappedPayloadWithDueDateVariants() = runTest {
|
||||||
|
TestServer().use { server ->
|
||||||
|
server.register(
|
||||||
|
path = "/api/v1/boards/board-1",
|
||||||
|
status = 200,
|
||||||
|
responseBody =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"board": {
|
||||||
|
"public_id": "board-1",
|
||||||
|
"name": "Roadmap",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "list-1",
|
||||||
|
"name": "Todo",
|
||||||
|
"cards": [
|
||||||
|
{
|
||||||
|
"publicId": "card-iso",
|
||||||
|
"name": "ISO",
|
||||||
|
"tags": [{"id": "tag-1", "name": "Urgent", "color": "#FF0000"}],
|
||||||
|
"dueAt": "2026-01-05T08:30:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"public_id": "card-epoch-num",
|
||||||
|
"title": "EpochNum",
|
||||||
|
"labels": [{"public_id": "tag-2", "title": "Backend", "colorHex": "#00FF00"}],
|
||||||
|
"due": 1735689600000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"publicId": "list-2",
|
||||||
|
"title": "Doing",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "card-epoch-string",
|
||||||
|
"title": "EpochString",
|
||||||
|
"data": [{"id": "tag-3", "name": "Ops", "hex": "#0000FF"}],
|
||||||
|
"due_at": "1735689600123"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "card-invalid",
|
||||||
|
"name": "Invalid",
|
||||||
|
"labels": [],
|
||||||
|
"dueDate": "not-a-date"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val client = HttpKanbnApiClient()
|
||||||
|
|
||||||
|
val result = client.getBoardDetail(server.baseUrl, "api-key", "board-1")
|
||||||
|
|
||||||
|
assertTrue(result is BoardsApiResult.Success<*>)
|
||||||
|
val detail = (result as BoardsApiResult.Success<*>).value as BoardDetail
|
||||||
|
assertEquals("board-1", detail.id)
|
||||||
|
assertEquals("Roadmap", detail.title)
|
||||||
|
assertEquals(2, detail.lists.size)
|
||||||
|
assertEquals("list-1", detail.lists[0].id)
|
||||||
|
assertEquals("Todo", detail.lists[0].title)
|
||||||
|
assertEquals("card-iso", detail.lists[0].cards[0].id)
|
||||||
|
assertEquals(Instant.parse("2026-01-05T08:30:00Z").toEpochMilli(), detail.lists[0].cards[0].dueAtEpochMillis)
|
||||||
|
assertEquals(1735689600000L, detail.lists[0].cards[1].dueAtEpochMillis)
|
||||||
|
assertEquals("tag-1", detail.lists[0].cards[0].tags[0].id)
|
||||||
|
assertEquals("Urgent", detail.lists[0].cards[0].tags[0].name)
|
||||||
|
assertEquals("#FF0000", detail.lists[0].cards[0].tags[0].colorHex)
|
||||||
|
assertEquals(1735689600123L, detail.lists[1].cards[0].dueAtEpochMillis)
|
||||||
|
assertNull(detail.lists[1].cards[1].dueAtEpochMillis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun getBoardDetailParsesDirectRootObject() = runTest {
|
||||||
|
TestServer().use { server ->
|
||||||
|
server.register(
|
||||||
|
path = "/api/v1/boards/b-2",
|
||||||
|
status = 200,
|
||||||
|
responseBody =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"id": "b-2",
|
||||||
|
"title": "Board Direct",
|
||||||
|
"lists": [
|
||||||
|
{
|
||||||
|
"id": "l-1",
|
||||||
|
"title": "List",
|
||||||
|
"cards": [
|
||||||
|
{
|
||||||
|
"id": "c-1",
|
||||||
|
"title": "Card",
|
||||||
|
"labels": [{"id": "t-1", "name": "Tag", "color": "#111111"}]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = HttpKanbnApiClient().getBoardDetail(server.baseUrl, "key", "b-2")
|
||||||
|
|
||||||
|
assertTrue(result is BoardsApiResult.Success<*>)
|
||||||
|
val detail = (result as BoardsApiResult.Success<*>).value as BoardDetail
|
||||||
|
assertEquals("b-2", detail.id)
|
||||||
|
assertEquals("Board Direct", detail.title)
|
||||||
|
assertEquals(1, detail.lists.size)
|
||||||
|
assertEquals("l-1", detail.lists[0].id)
|
||||||
|
assertEquals("c-1", detail.lists[0].cards[0].id)
|
||||||
|
assertEquals("t-1", detail.lists[0].cards[0].tags[0].id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun requestMappingMatchesContractForBoardDetailAndMutations() = runTest {
|
||||||
|
TestServer().use { server ->
|
||||||
|
server.register(
|
||||||
|
path = "/api/v1/boards/b-1",
|
||||||
|
status = 200,
|
||||||
|
responseBody = """{"id":"b-1","title":"Board","lists":[]}""",
|
||||||
|
)
|
||||||
|
server.register(path = "/api/v1/lists/l-1", status = 200, responseBody = "{}")
|
||||||
|
server.register(path = "/api/v1/cards/c-1", status = 200, responseBody = "{}")
|
||||||
|
server.register(path = "/api/v1/cards/c-2", status = 200, responseBody = "{}")
|
||||||
|
|
||||||
|
val client = HttpKanbnApiClient()
|
||||||
|
val boardResult = client.getBoardDetail(server.baseUrl, "api-123", "b-1")
|
||||||
|
val renameResult = client.renameList(server.baseUrl, "api-123", "l-1", " New title ")
|
||||||
|
val moveResult = client.moveCard(server.baseUrl, "api-123", "c-1", "l-9")
|
||||||
|
val deleteResult = client.deleteCard(server.baseUrl, "api-123", "c-2")
|
||||||
|
|
||||||
|
assertTrue(boardResult is BoardsApiResult.Success<*>)
|
||||||
|
assertTrue(renameResult is BoardsApiResult.Success<*>)
|
||||||
|
assertTrue(moveResult is BoardsApiResult.Success<*>)
|
||||||
|
assertTrue(deleteResult is BoardsApiResult.Success<*>)
|
||||||
|
|
||||||
|
val boardRequest = server.findRequest("GET", "/api/v1/boards/b-1")
|
||||||
|
assertNotNull(boardRequest)
|
||||||
|
assertEquals("api-123", boardRequest?.apiKey)
|
||||||
|
|
||||||
|
val renameRequest = server.findRequest("PATCH", "/api/v1/lists/l-1")
|
||||||
|
assertNotNull(renameRequest)
|
||||||
|
assertEquals("{\"name\":\"New title\"}", renameRequest?.body)
|
||||||
|
assertEquals("api-123", renameRequest?.apiKey)
|
||||||
|
|
||||||
|
val moveRequest = server.findRequest("PATCH", "/api/v1/cards/c-1")
|
||||||
|
assertNotNull(moveRequest)
|
||||||
|
assertEquals("{\"listId\":\"l-9\"}", moveRequest?.body)
|
||||||
|
assertEquals("api-123", moveRequest?.apiKey)
|
||||||
|
|
||||||
|
val deleteRequest = server.findRequest("DELETE", "/api/v1/cards/c-2")
|
||||||
|
assertNotNull(deleteRequest)
|
||||||
|
assertEquals("api-123", deleteRequest?.apiKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun serverMessageIsPropagatedWithFallbackWhenMissing() = runTest {
|
||||||
|
TestServer().use { server ->
|
||||||
|
server.register(
|
||||||
|
path = "/api/v1/lists/l-error",
|
||||||
|
status = 400,
|
||||||
|
responseBody = """{"message":"List is locked"}""",
|
||||||
|
)
|
||||||
|
server.register(
|
||||||
|
path = "/api/v1/lists/l-fallback",
|
||||||
|
status = 503,
|
||||||
|
responseBody = "{}",
|
||||||
|
)
|
||||||
|
|
||||||
|
val client = HttpKanbnApiClient()
|
||||||
|
val messageResult = client.renameList(server.baseUrl, "api", "l-error", "Name")
|
||||||
|
val fallbackResult = client.renameList(server.baseUrl, "api", "l-fallback", "Name")
|
||||||
|
|
||||||
|
assertTrue(messageResult is BoardsApiResult.Failure)
|
||||||
|
assertEquals("List is locked", (messageResult as BoardsApiResult.Failure).message)
|
||||||
|
assertTrue(fallbackResult is BoardsApiResult.Failure)
|
||||||
|
assertEquals("Server error: 503", (fallbackResult as BoardsApiResult.Failure).message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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, status: Int, responseBody: String) {
|
||||||
|
responses["GET $path"] = status to responseBody
|
||||||
|
responses["PATCH $path"] = status to responseBody
|
||||||
|
responses["DELETE $path"] = status to responseBody
|
||||||
|
responses["POST $path"] = status to responseBody
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
var methodOverride: String? = null
|
||||||
|
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 == "x-http-method-override") {
|
||||||
|
methodOverride = 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)
|
||||||
|
val effectiveMethod = methodOverride ?: method
|
||||||
|
requests += CapturedRequest(method = effectiveMethod, path = path, body = body, apiKey = apiKey)
|
||||||
|
|
||||||
|
val response = responses["$effectiveMethod $path"] ?: responses["$method $path"] ?: (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"
|
||||||
|
400 -> "Bad Request"
|
||||||
|
404 -> "Not Found"
|
||||||
|
503 -> "Service Unavailable"
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
|
|||||||
import space.hackenslacker.kanbn4droid.app.auth.AuthResult
|
import space.hackenslacker.kanbn4droid.app.auth.AuthResult
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
|
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
|
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
|
||||||
|
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetail
|
||||||
|
|
||||||
class BoardsRepositoryTest {
|
class BoardsRepositoryTest {
|
||||||
|
|
||||||
@@ -250,5 +251,31 @@ class BoardsRepositoryTest {
|
|||||||
lastDeletedId = boardId
|
lastDeletedId = boardId
|
||||||
return deleteBoardResult
|
return deleteBoardResult
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getBoardDetail(baseUrl: String, apiKey: String, boardId: String): BoardsApiResult<BoardDetail> {
|
||||||
|
return BoardsApiResult.Failure("Not used in this test")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun renameList(
|
||||||
|
baseUrl: String,
|
||||||
|
apiKey: String,
|
||||||
|
listId: String,
|
||||||
|
newTitle: String,
|
||||||
|
): BoardsApiResult<Unit> {
|
||||||
|
return BoardsApiResult.Failure("Not used in this test")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun moveCard(
|
||||||
|
baseUrl: String,
|
||||||
|
apiKey: String,
|
||||||
|
cardId: String,
|
||||||
|
targetListId: String,
|
||||||
|
): BoardsApiResult<Unit> {
|
||||||
|
return BoardsApiResult.Failure("Not used in this test")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteCard(baseUrl: String, apiKey: String, cardId: String): BoardsApiResult<Unit> {
|
||||||
|
return BoardsApiResult.Failure("Not used in this test")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
|
|||||||
import space.hackenslacker.kanbn4droid.app.auth.AuthResult
|
import space.hackenslacker.kanbn4droid.app.auth.AuthResult
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
|
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
|
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
|
||||||
|
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetail
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class BoardsViewModelTest {
|
class BoardsViewModelTest {
|
||||||
@@ -194,5 +195,31 @@ class BoardsViewModelTest {
|
|||||||
lastDeletedId = boardId
|
lastDeletedId = boardId
|
||||||
return deleteBoardResult
|
return deleteBoardResult
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getBoardDetail(baseUrl: String, apiKey: String, boardId: String): BoardsApiResult<BoardDetail> {
|
||||||
|
return BoardsApiResult.Failure("Not used in this test")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun renameList(
|
||||||
|
baseUrl: String,
|
||||||
|
apiKey: String,
|
||||||
|
listId: String,
|
||||||
|
newTitle: String,
|
||||||
|
): BoardsApiResult<Unit> {
|
||||||
|
return BoardsApiResult.Failure("Not used in this test")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun moveCard(
|
||||||
|
baseUrl: String,
|
||||||
|
apiKey: String,
|
||||||
|
cardId: String,
|
||||||
|
targetListId: String,
|
||||||
|
): BoardsApiResult<Unit> {
|
||||||
|
return BoardsApiResult.Failure("Not used in this test")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteCard(baseUrl: String, apiKey: String, cardId: String): BoardsApiResult<Unit> {
|
||||||
|
return BoardsApiResult.Failure("Not used in this test")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user