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>,
): BoardsApiResult<CreatedEntityRef> {
return withContext(Dispatchers.IO) {
val payloadFields = mutableListOf(
"\"listPublicId\":\"${jsonEscape(listPublicId)}\"",
"\"title\":\"${jsonEscape(title)}\"",
"\"index\":0",
"\"labelPublicIds\":${jsonStringArray(tagPublicIds)}",
val canonicalPayload = buildCreateCardPayload(
listPublicId = listPublicId,
title = title,
description = description,
dueDate = dueDate,
tagPublicIds = tagPublicIds,
aliasKeys = false,
)
if (description != null) {
payloadFields += "\"description\":\"${jsonEscape(description)}\""
val canonicalResult = request(
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\""
if (canonicalResult is BoardsApiResult.Success) {
return@withContext canonicalResult
}
val payload = "{${payloadFields.joinToString(",")}}"
val aliasPayload = buildCreateCardPayload(
listPublicId = listPublicId,
title = title,
description = description,
dueDate = dueDate,
tagPublicIds = tagPublicIds,
aliasKeys = true,
)
request(
baseUrl = baseUrl,
path = "/api/v1/cards",
method = "POST",
apiKey = apiKey,
body = payload,
body = aliasPayload,
) { code, body ->
if (code in 200..299) {
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(
baseUrl: String,
apiKey: String,
@@ -936,15 +996,11 @@ class HttpKanbnApiClient : KanbnApiClient {
private fun isTemplateBoard(item: JSONObject): Boolean {
val typeKeys = listOf("type", "boardType", "kind")
val hasTemplateType = typeKeys.any { key ->
return typeKeys.any { key ->
item.optString(key)
.trim()
.equals("template", ignoreCase = true)
}
if (hasTemplateType) {
return true
}
return item.optBoolean("isTemplate", false)
}
private fun parseSingleBoard(body: String, fallbackName: String): BoardSummary {
@@ -99,7 +99,7 @@ class HttpKanbnApiClientBoardDetailParsingTest {
}
@Test
fun createCard_sendsTopIndex_andOmittedDueDateWhenNull() = runTest {
fun createCard_sendsRequiredValidationFields_whenOptionalsAreNull() = runTest {
TestServer().use { server ->
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<*>)
val request = server.findRequest("POST", "/api/v1/cards")
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("\"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
fun getBoardDetailParsesWrappedPayloadWithDueDateVariants() = runTest {
TestServer().use { server ->
@@ -32,7 +32,6 @@ class HttpKanbnApiClientBoardsParsingTest {
{"id":"board-1","name":"Roadmap"},
{"id":"tmpl-1","name":"Template A","type":"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 "},
{"publicId":"board-2","title":"Backlog"}
]
@@ -59,7 +58,7 @@ class HttpKanbnApiClientBoardsParsingTest {
"boards": [
{"id":"tmpl-1","name":"Template A","type":"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(),
@@ -109,65 +109,6 @@ class BoardsRepositoryTest {
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
fun createBoardTrimsNameAndPassesTemplateId() = runTest {
val fakeApi = FakeBoardsApiClient().apply {
@@ -248,7 +189,6 @@ class BoardsRepositoryTest {
private class FakeBoardsApiClient : KanbnApiClient {
var listBoardsResult: BoardsApiResult<List<BoardSummary>> = BoardsApiResult.Success(emptyList())
var listTemplatesResult: BoardsApiResult<List<BoardTemplate>> = BoardsApiResult.Success(emptyList())
var workspacesResult: BoardsApiResult<List<WorkspaceSummary>> =
BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main")))
var createBoardResult: BoardsApiResult<BoardSummary> = BoardsApiResult.Success(BoardSummary("new", "New"))
@@ -285,8 +225,7 @@ class BoardsRepositoryTest {
apiKey: String,
workspaceId: String,
): BoardsApiResult<List<BoardTemplate>> {
lastWorkspaceId = workspaceId
return listTemplatesResult
return BoardsApiResult.Success(emptyList())
}
override suspend fun createBoard(