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 43b65ad..13e8adb 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 @@ -4,6 +4,7 @@ import java.io.IOException import java.net.HttpURLConnection import java.net.URL import java.time.Instant +import java.time.LocalDate import org.json.JSONArray import org.json.JSONObject import kotlinx.coroutines.Dispatchers @@ -79,7 +80,7 @@ interface KanbnApiClient { listPublicId: String, title: String, description: String?, - dueDate: String?, + dueDate: LocalDate?, tagPublicIds: List, ): BoardsApiResult { return BoardsApiResult.Failure("Card creation is not implemented.") @@ -300,16 +301,18 @@ class HttpKanbnApiClient : KanbnApiClient { appendIndex: Int, ): BoardsApiResult { return withContext(Dispatchers.IO) { - val payload = JSONObject() - .put("boardPublicId", boardPublicId) - .put("name", title) - .put("index", appendIndex) + val payload = + "{" + + "\"boardPublicId\":\"${jsonEscape(boardPublicId)}\"," + + "\"name\":\"${jsonEscape(title)}\"," + + "\"index\":$appendIndex" + + "}" request( baseUrl = baseUrl, path = "/api/v1/lists", method = "POST", apiKey = apiKey, - body = payload.toString(), + body = payload, ) { code, body -> if (code in 200..299) { parseCreatedEntityRef(body) @@ -328,23 +331,29 @@ class HttpKanbnApiClient : KanbnApiClient { listPublicId: String, title: String, description: String?, - dueDate: String?, + dueDate: LocalDate?, tagPublicIds: List, ): BoardsApiResult { return withContext(Dispatchers.IO) { - val payload = JSONObject() - .put("listPublicId", listPublicId) - .put("title", title) - .put("description", description ?: "") - .put("dueDate", dueDate) - .put("index", 0) - .put("labelPublicIds", JSONArray(tagPublicIds)) + val payloadFields = mutableListOf( + "\"listPublicId\":\"${jsonEscape(listPublicId)}\"", + "\"title\":\"${jsonEscape(title)}\"", + "\"index\":0", + "\"labelPublicIds\":${jsonStringArray(tagPublicIds)}", + ) + if (description != null) { + payloadFields += "\"description\":\"${jsonEscape(description)}\"" + } + if (dueDate != null) { + payloadFields += "\"dueDate\":\"${dueDate}T00:00:00Z\"" + } + val payload = "{${payloadFields.joinToString(",")}}" request( baseUrl = baseUrl, path = "/api/v1/cards", method = "POST", apiKey = apiKey, - body = payload.toString(), + body = payload, ) { code, body -> if (code in 200..299) { parseCreatedEntityRef(body) @@ -890,6 +899,10 @@ class HttpKanbnApiClient : KanbnApiClient { return builder.toString() } + private fun jsonStringArray(values: List): String { + return values.joinToString(prefix = "[", postfix = "]", separator = ",") { "\"${jsonEscape(it)}\"" } + } + private class MiniJsonParser(private val input: String) { private var index = 0 diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailRepository.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailRepository.kt index 0501716..3086335 100644 --- a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailRepository.kt +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailRepository.kt @@ -3,6 +3,7 @@ package space.hackenslacker.kanbn4droid.app.boarddetail import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import java.time.LocalDate import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient import space.hackenslacker.kanbn4droid.app.auth.LabelDetail @@ -73,7 +74,7 @@ class BoardDetailRepository( listId: String, title: String, description: String?, - dueDate: String?, + dueDate: LocalDate?, tagIds: Collection, ): BoardsApiResult { val normalizedListId = listId.trim() @@ -87,7 +88,6 @@ class BoardDetailRepository( } val normalizedDescription = description?.trim()?.takeIf { it.isNotBlank() } - val normalizedDueDate = dueDate?.trim()?.takeIf { it.isNotBlank() } val normalizedTagIds = tagIds .map { it.trim() } .filter { it.isNotBlank() } @@ -104,7 +104,7 @@ class BoardDetailRepository( listPublicId = normalizedListId, title = normalizedTitle, description = normalizedDescription, - dueDate = normalizedDueDate, + dueDate = dueDate, tagPublicIds = normalizedTagIds, ) } diff --git a/app/src/test/java/space/hackenslacker/kanbn4droid/app/auth/HttpKanbnApiClientBoardDetailParsingTest.kt b/app/src/test/java/space/hackenslacker/kanbn4droid/app/auth/HttpKanbnApiClientBoardDetailParsingTest.kt index 4949cd5..25892ca 100644 --- a/app/src/test/java/space/hackenslacker/kanbn4droid/app/auth/HttpKanbnApiClientBoardDetailParsingTest.kt +++ b/app/src/test/java/space/hackenslacker/kanbn4droid/app/auth/HttpKanbnApiClientBoardDetailParsingTest.kt @@ -6,6 +6,7 @@ import java.net.InetSocketAddress import java.net.ServerSocket import java.net.Socket import java.time.Instant +import java.time.LocalDate import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.Executors import java.util.concurrent.TimeUnit @@ -21,6 +22,52 @@ import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult class HttpKanbnApiClientBoardDetailParsingTest { + @Test + fun createCardFormatsLocalDateAsUtcMidnightInPayload() = runTest { + TestServer().use { server -> + server.register(path = "/api/v1/cards", method = "POST", status = 200, responseBody = """{"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 request = server.findRequest("POST", "/api/v1/cards") + assertNotNull(request) + assertTrue(request?.body?.contains("\"dueDate\":\"2026-03-16T00:00:00Z\"") == true) + assertTrue(request?.body?.contains("\"description\":\"Description\"") == true) + } + } + + @Test + fun createCardOmitsNullDueDateAndDescriptionInPayload() = runTest { + TestServer().use { server -> + server.register(path = "/api/v1/cards", method = "POST", status = 200, responseBody = """{"publicId":"card-new"}""") + + val result = HttpKanbnApiClient().createCard( + baseUrl = server.baseUrl, + apiKey = "api", + listPublicId = "list-1", + title = "Card", + description = null, + dueDate = null, + tagPublicIds = emptyList(), + ) + + assertTrue(result is BoardsApiResult.Success<*>) + val request = server.findRequest("POST", "/api/v1/cards") + assertNotNull(request) + assertTrue(request?.body?.contains("\"dueDate\"") == false) + assertTrue(request?.body?.contains("\"description\"") == false) + } + } + @Test fun getBoardDetailParsesWrappedPayloadWithDueDateVariants() = runTest { TestServer().use { server -> diff --git a/app/src/test/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailRepositoryTest.kt b/app/src/test/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailRepositoryTest.kt index f375578..b1632ed 100644 --- a/app/src/test/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailRepositoryTest.kt +++ b/app/src/test/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailRepositoryTest.kt @@ -1,6 +1,7 @@ package space.hackenslacker.kanbn4droid.app.boarddetail import kotlinx.coroutines.test.runTest +import java.time.LocalDate import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -216,7 +217,7 @@ class BoardDetailRepositoryTest { listId = " list-1 ", title = " Card title ", description = " Description ", - dueDate = "2026-03-16T10:15:30Z", + dueDate = LocalDate.of(2026, 3, 16), tagIds = listOf(" tag-1 ", "", "tag-2", "tag-1", " ", " tag-2"), ) @@ -224,7 +225,7 @@ class BoardDetailRepositoryTest { assertEquals("list-1", apiClient.lastCreateCardListPublicId) assertEquals("Card title", apiClient.lastCreateCardTitle) assertEquals("Description", apiClient.lastCreateCardDescription) - assertEquals("2026-03-16T10:15:30Z", apiClient.lastCreateCardDueDate) + assertEquals(LocalDate.of(2026, 3, 16), apiClient.lastCreateCardDueDate) assertEquals(listOf("tag-1", "tag-2"), apiClient.lastCreateCardTagPublicIds) } @@ -509,7 +510,7 @@ class BoardDetailRepositoryTest { var lastCreateCardListPublicId: String? = null var lastCreateCardTitle: String? = null var lastCreateCardDescription: String? = null - var lastCreateCardDueDate: String? = null + var lastCreateCardDueDate: LocalDate? = null var lastCreateCardTagPublicIds: List = emptyList() override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = AuthResult.Success @@ -558,7 +559,7 @@ class BoardDetailRepositoryTest { listPublicId: String, title: String, description: String?, - dueDate: String?, + dueDate: LocalDate?, tagPublicIds: List, ): BoardsApiResult { lastCreateCardListPublicId = listPublicId