Compare commits

..

4 Commits

Author SHA1 Message Date
WallyHackenslacker cdb8ba58da merge: integrate feat/boards-list-exclude-templates into master 2026-04-30 15:07:40 -04:00
WallyHackenslacker de59a1d0e7 fix: filter template boards by type in board list parsing 2026-04-30 15:04:52 -04:00
WallyHackenslacker d162841708 fix: keep template boards out of boards list 2026-04-30 14:56:50 -04:00
WallyHackenslacker 29e859bc01 fix: satisfy card creation validation payload requirements
Send required create-card fields and retry with alias keys when canonical payload is rejected, so board add-card works across stricter Kan API variants.
2026-04-30 14:43:35 -04:00
4 changed files with 108 additions and 83 deletions
@@ -387,25 +387,47 @@ class HttpKanbnApiClient : KanbnApiClient {
tagPublicIds: List<String>, tagPublicIds: List<String>,
): BoardsApiResult<CreatedEntityRef> { ): BoardsApiResult<CreatedEntityRef> {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
val payloadFields = mutableListOf( val canonicalPayload = buildCreateCardPayload(
"\"listPublicId\":\"${jsonEscape(listPublicId)}\"", listPublicId = listPublicId,
"\"title\":\"${jsonEscape(title)}\"", title = title,
"\"index\":0", description = description,
"\"labelPublicIds\":${jsonStringArray(tagPublicIds)}", dueDate = dueDate,
tagPublicIds = tagPublicIds,
aliasKeys = false,
) )
if (description != null) { val canonicalResult = request(
payloadFields += "\"description\":\"${jsonEscape(description)}\"" baseUrl = baseUrl,
path = "/api/v1/cards",
method = "POST",
apiKey = apiKey,
body = canonicalPayload,
) { code, body ->
if (code in 200..299) {
parseCreatedEntityRef(body)
?.let { BoardsApiResult.Success(it) }
?: BoardsApiResult.Failure("Malformed create card response.")
} else {
BoardsApiResult.Failure(serverMessage(body, code))
} }
if (dueDate != null) {
payloadFields += "\"dueDate\":\"${dueDate}T00:00:00Z\""
} }
val payload = "{${payloadFields.joinToString(",")}}" if (canonicalResult is BoardsApiResult.Success) {
return@withContext canonicalResult
}
val aliasPayload = buildCreateCardPayload(
listPublicId = listPublicId,
title = title,
description = description,
dueDate = dueDate,
tagPublicIds = tagPublicIds,
aliasKeys = true,
)
request( request(
baseUrl = baseUrl, baseUrl = baseUrl,
path = "/api/v1/cards", path = "/api/v1/cards",
method = "POST", method = "POST",
apiKey = apiKey, apiKey = apiKey,
body = payload, body = aliasPayload,
) { code, body -> ) { code, body ->
if (code in 200..299) { if (code in 200..299) {
parseCreatedEntityRef(body) parseCreatedEntityRef(body)
@@ -418,6 +440,44 @@ class HttpKanbnApiClient : KanbnApiClient {
} }
} }
private fun buildCreateCardPayload(
listPublicId: String,
title: String,
description: String?,
dueDate: LocalDate?,
tagPublicIds: List<String>,
aliasKeys: Boolean,
): String {
val payloadFields = mutableListOf(
"\"listPublicId\":\"${jsonEscape(listPublicId)}\"",
if (aliasKeys) {
"\"name\":\"${jsonEscape(title)}\""
} else {
"\"title\":\"${jsonEscape(title)}\""
},
"\"description\":\"${jsonEscape(description.orEmpty())}\"",
"\"labelPublicIds\":${jsonStringArray(tagPublicIds)}",
"\"memberPublicIds\":[]",
"\"position\":\"start\"",
)
if (aliasKeys) {
payloadFields += if (aliasKeys) {
"\"body\":\"${jsonEscape(description.orEmpty())}\""
} else {
"\"description\":\"${jsonEscape(description.orEmpty())}\""
}
payloadFields += "\"index\":0"
}
if (dueDate != null) {
payloadFields += if (aliasKeys) {
"\"dueAt\":\"${dueDate}T00:00:00Z\""
} else {
"\"dueDate\":\"${dueDate}T00:00:00Z\""
}
}
return "{${payloadFields.joinToString(",")}}"
}
override suspend fun moveCard( override suspend fun moveCard(
baseUrl: String, baseUrl: String,
apiKey: String, apiKey: String,
@@ -936,15 +996,11 @@ class HttpKanbnApiClient : KanbnApiClient {
private fun isTemplateBoard(item: JSONObject): Boolean { private fun isTemplateBoard(item: JSONObject): Boolean {
val typeKeys = listOf("type", "boardType", "kind") val typeKeys = listOf("type", "boardType", "kind")
val hasTemplateType = typeKeys.any { key -> return typeKeys.any { key ->
item.optString(key) item.optString(key)
.trim() .trim()
.equals("template", ignoreCase = true) .equals("template", ignoreCase = true)
} }
if (hasTemplateType) {
return true
}
return item.optBoolean("isTemplate", false)
} }
private fun parseSingleBoard(body: String, fallbackName: String): BoardSummary { private fun parseSingleBoard(body: String, fallbackName: String): BoardSummary {
@@ -99,7 +99,7 @@ class HttpKanbnApiClientBoardDetailParsingTest {
} }
@Test @Test
fun createCard_sendsTopIndex_andOmittedDueDateWhenNull() = runTest { fun createCard_sendsRequiredValidationFields_whenOptionalsAreNull() = runTest {
TestServer().use { server -> TestServer().use { server ->
server.register(path = "/api/v1/cards", method = "POST", status = 200, responseBody = """{"publicId":"card-new"}""") server.register(path = "/api/v1/cards", method = "POST", status = 200, responseBody = """{"publicId":"card-new"}""")
@@ -116,9 +116,10 @@ class HttpKanbnApiClientBoardDetailParsingTest {
assertTrue(result is BoardsApiResult.Success<*>) assertTrue(result is BoardsApiResult.Success<*>)
val request = server.findRequest("POST", "/api/v1/cards") val request = server.findRequest("POST", "/api/v1/cards")
assertNotNull(request) assertNotNull(request)
assertTrue(request?.body?.contains("\"index\":0") == true) assertTrue(request?.body?.contains("\"position\":\"start\"") == true)
assertTrue(request?.body?.contains("\"memberPublicIds\":[]") == true)
assertTrue(request?.body?.contains("\"description\":\"\"") == true)
assertTrue(request?.body?.contains("\"dueDate\"") == false) assertTrue(request?.body?.contains("\"dueDate\"") == false)
assertTrue(request?.body?.contains("\"description\"") == false)
} }
} }
@@ -172,6 +173,36 @@ class HttpKanbnApiClientBoardDetailParsingTest {
} }
} }
@Test
fun createCard_retriesWithAliasPayloadWhenServerRejectsCanonicalFields() = runTest {
TestServer().use { server ->
server.registerSequence(
path = "/api/v1/cards",
method = "POST",
responses = listOf(
400 to "{}",
200 to """{"card":{"publicId":"card-new"}}""",
),
)
val result = HttpKanbnApiClient().createCard(
baseUrl = server.baseUrl,
apiKey = "api",
listPublicId = "list-1",
title = "Card",
description = "Description",
dueDate = LocalDate.of(2026, 3, 16),
tagPublicIds = listOf("tag-1"),
)
assertTrue(result is BoardsApiResult.Success<*>)
val requests = server.findRequests("POST", "/api/v1/cards")
assertEquals(2, requests.size)
assertTrue(requests[0].body.contains("\"title\":\"Card\""))
assertTrue(requests[1].body.contains("\"name\":\"Card\""))
}
}
@Test @Test
fun getBoardDetailParsesWrappedPayloadWithDueDateVariants() = runTest { fun getBoardDetailParsesWrappedPayloadWithDueDateVariants() = runTest {
TestServer().use { server -> TestServer().use { server ->
@@ -32,7 +32,6 @@ class HttpKanbnApiClientBoardsParsingTest {
{"id":"board-1","name":"Roadmap"}, {"id":"board-1","name":"Roadmap"},
{"id":"tmpl-1","name":"Template A","type":"template"}, {"id":"tmpl-1","name":"Template A","type":"template"},
{"id":"tmpl-2","name":"Template B","kind":"template"}, {"id":"tmpl-2","name":"Template B","kind":"template"},
{"id":"tmpl-3","name":"Template C","isTemplate":true},
{"id":"tmpl-4","name":"Template D","boardType":" Template "}, {"id":"tmpl-4","name":"Template D","boardType":" Template "},
{"publicId":"board-2","title":"Backlog"} {"publicId":"board-2","title":"Backlog"}
] ]
@@ -59,7 +58,7 @@ class HttpKanbnApiClientBoardsParsingTest {
"boards": [ "boards": [
{"id":"tmpl-1","name":"Template A","type":"template"}, {"id":"tmpl-1","name":"Template A","type":"template"},
{"id":"tmpl-2","name":"Template B","kind":"template"}, {"id":"tmpl-2","name":"Template B","kind":"template"},
{"id":"tmpl-3","name":"Template C","isTemplate":true} {"id":"tmpl-3","name":"Template C","boardType":"TEMPLATE"}
] ]
} }
""".trimIndent(), """.trimIndent(),
@@ -109,65 +109,6 @@ class BoardsRepositoryTest {
assertEquals("No workspaces available for this account.", (result as BoardsApiResult.Failure).message) assertEquals("No workspaces available for this account.", (result as BoardsApiResult.Failure).message)
} }
@Test
fun listBoardsExcludesBoardsWhoseIdsMatchTemplates() = runTest {
val fakeApi = FakeBoardsApiClient().apply {
workspacesResult = BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main")))
listBoardsResult = BoardsApiResult.Success(
listOf(
BoardSummary("board-1", "Juegos"),
BoardSummary("tpl-1", "GTD Simplificado"),
BoardSummary("tpl-2", "Kanban"),
BoardSummary("board-2", "Task Tension"),
),
)
listTemplatesResult = BoardsApiResult.Success(
listOf(
BoardTemplate("tpl-1", "GTD Simplificado"),
BoardTemplate("tpl-2", "Kanban"),
),
)
}
val repository = BoardsRepository(
sessionStore = InMemorySessionStore("https://kan.bn/"),
apiKeyStore = InMemoryApiKeyStore("api"),
apiClient = fakeApi,
)
val result = repository.listBoards()
assertTrue(result is BoardsApiResult.Success)
val boards = (result as BoardsApiResult.Success).value
assertEquals(listOf("board-1", "board-2"), boards.map { it.id })
}
@Test
fun listBoardsReturnsApiBoardsWhenTemplateListingFails() = runTest {
val fakeApi = FakeBoardsApiClient().apply {
workspacesResult = BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main")))
listBoardsResult = BoardsApiResult.Success(
listOf(
BoardSummary("board-1", "Juegos"),
BoardSummary("tpl-1", "GTD Simplificado"),
),
)
listTemplatesResult = BoardsApiResult.Failure("Template endpoint unavailable")
}
val repository = BoardsRepository(
sessionStore = InMemorySessionStore("https://kan.bn/"),
apiKeyStore = InMemoryApiKeyStore("api"),
apiClient = fakeApi,
)
val result = repository.listBoards()
assertTrue(result is BoardsApiResult.Success)
val boards = (result as BoardsApiResult.Success).value
assertEquals(listOf("board-1", "tpl-1"), boards.map { it.id })
}
@Test @Test
fun createBoardTrimsNameAndPassesTemplateId() = runTest { fun createBoardTrimsNameAndPassesTemplateId() = runTest {
val fakeApi = FakeBoardsApiClient().apply { val fakeApi = FakeBoardsApiClient().apply {
@@ -248,7 +189,6 @@ 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 workspacesResult: BoardsApiResult<List<WorkspaceSummary>> = var workspacesResult: BoardsApiResult<List<WorkspaceSummary>> =
BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main"))) 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"))
@@ -285,8 +225,7 @@ class BoardsRepositoryTest {
apiKey: String, apiKey: String,
workspaceId: String, workspaceId: String,
): BoardsApiResult<List<BoardTemplate>> { ): BoardsApiResult<List<BoardTemplate>> {
lastWorkspaceId = workspaceId return BoardsApiResult.Success(emptyList())
return listTemplatesResult
} }
override suspend fun createBoard( override suspend fun createBoard(