Compare commits

..

3 Commits

Author SHA1 Message Date
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
WallyHackenslacker 784f92bd40 fix: block board click-through while opening settings 2026-04-30 12:57:53 -04:00
WallyHackenslacker 11a368b7c8 fix: defer settings fragment transaction until dialog is shown 2026-04-30 12:44:29 -04:00
7 changed files with 197 additions and 23 deletions
@@ -18,6 +18,7 @@ import androidx.test.espresso.assertion.ViewAssertions.doesNotExist
import androidx.test.espresso.intent.Intents import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent
import androidx.test.espresso.intent.matcher.IntentMatchers.hasExtra import androidx.test.espresso.intent.matcher.IntentMatchers.hasExtra
import androidx.test.espresso.intent.VerificationModes.times
import androidx.test.espresso.matcher.RootMatchers.isDialog import androidx.test.espresso.matcher.RootMatchers.isDialog
import androidx.test.espresso.matcher.ViewMatchers.isEnabled import androidx.test.espresso.matcher.ViewMatchers.isEnabled
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
@@ -578,6 +579,30 @@ class BoardsFlowTest {
onView(withId(R.id.settingsSaveAndCloseButton)).check(matches(isDisplayed())) onView(withId(R.id.settingsSaveAndCloseButton)).check(matches(isDisplayed()))
} }
@Test
fun boardTapIsIgnoredWhileSettingsOpenIsPending() {
MainActivity.dependencies.apiClientFactory = {
FakeBoardsApiClient(
boards = mutableListOf(BoardSummary("1", "Alpha")),
templates = emptyList(),
)
}
val scenario = ActivityScenario.launch(BoardsActivity::class.java)
scenario.onActivity { activity ->
val field = activity.javaClass.getDeclaredField("pendingOpenSettingsAfterDrawerClose")
field.isAccessible = true
field.setBoolean(activity, true)
}
onView(withText("Alpha")).perform(click())
Intents.intended(
hasComponent(space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetailActivity::class.java.name),
times(0),
)
}
private fun openSettingsFromDrawer() { private fun openSettingsFromDrawer() {
onView(withId(R.id.boardsDrawerLayout)).perform(DrawerActions.open()) onView(withId(R.id.boardsDrawerLayout)).perform(DrawerActions.open())
onView(withId(R.id.drawerSettingsButton)).perform(click()) onView(withId(R.id.drawerSettingsButton)).perform(click())
@@ -43,6 +43,7 @@ import space.hackenslacker.kanbn4droid.app.boards.BoardsUiState
import space.hackenslacker.kanbn4droid.app.boards.BoardsViewModel import space.hackenslacker.kanbn4droid.app.boards.BoardsViewModel
import space.hackenslacker.kanbn4droid.app.boards.BoardsDrawerWidthCalculator import space.hackenslacker.kanbn4droid.app.boards.BoardsDrawerWidthCalculator
import space.hackenslacker.kanbn4droid.app.boards.DrawerDataErrorCode import space.hackenslacker.kanbn4droid.app.boards.DrawerDataErrorCode
import space.hackenslacker.kanbn4droid.app.boards.shouldIgnoreBoardClick
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetailActivity import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetailActivity
import space.hackenslacker.kanbn4droid.app.settings.SettingsDialogFragment import space.hackenslacker.kanbn4droid.app.settings.SettingsDialogFragment
@@ -140,7 +141,11 @@ class BoardsActivity : AppCompatActivity() {
private fun setupRecycler() { private fun setupRecycler() {
boardsAdapter = BoardsAdapter( boardsAdapter = BoardsAdapter(
onBoardClick = { board -> navigateToBoard(board) }, onBoardClick = { board ->
if (!shouldIgnoreBoardClick()) {
navigateToBoard(board)
}
},
onBoardLongClick = { board -> showDeleteConfirmation(board) }, onBoardLongClick = { board -> showDeleteConfirmation(board) },
) )
recyclerView.layoutManager = LinearLayoutManager(this) recyclerView.layoutManager = LinearLayoutManager(this)
@@ -458,6 +463,13 @@ class BoardsActivity : AppCompatActivity() {
.show(supportFragmentManager, SettingsDialogFragment.TAG) .show(supportFragmentManager, SettingsDialogFragment.TAG)
} }
private fun shouldIgnoreBoardClick(): Boolean {
return shouldIgnoreBoardClick(
pendingOpenSettingsAfterDrawerClose = pendingOpenSettingsAfterDrawerClose,
isDrawerOpen = drawerLayout.isDrawerOpen(GravityCompat.START),
)
}
private fun navigateToBoard(board: BoardSummary) { private fun navigateToBoard(board: BoardSummary) {
startActivity( startActivity(
Intent(this, BoardDetailActivity::class.java) Intent(this, BoardDetailActivity::class.java)
@@ -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,
@@ -0,0 +1,8 @@
package space.hackenslacker.kanbn4droid.app.boards
internal fun shouldIgnoreBoardClick(
pendingOpenSettingsAfterDrawerClose: Boolean,
isDrawerOpen: Boolean,
): Boolean {
return pendingOpenSettingsAfterDrawerClose || isDrawerOpen
}
@@ -47,14 +47,6 @@ class SettingsDialogFragment : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialogView = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_settings, null) val dialogView = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_settings, null)
childFragmentManager.commitNow {
replace(
R.id.settingsFragmentContainer,
SettingsPreferencesFragment(),
SETTINGS_PREFS_TAG,
)
}
progress = dialogView.findViewById(R.id.settingsApplyProgress) progress = dialogView.findViewById(R.id.settingsApplyProgress)
errorText = dialogView.findViewById(R.id.settingsErrorText) errorText = dialogView.findViewById(R.id.settingsErrorText)
saveButton = dialogView.findViewById(R.id.settingsSaveAndCloseButton) saveButton = dialogView.findViewById(R.id.settingsSaveAndCloseButton)
@@ -69,6 +61,15 @@ class SettingsDialogFragment : DialogFragment() {
dialog.setCanceledOnTouchOutside(false) dialog.setCanceledOnTouchOutside(false)
dialog.setOnShowListener { dialog.setOnShowListener {
if (findPreferencesFragment() == null) {
childFragmentManager.commitNow {
replace(
R.id.settingsFragmentContainer,
SettingsPreferencesFragment(),
SETTINGS_PREFS_TAG,
)
}
}
saveButton?.setOnClickListener { saveButton?.setOnClickListener {
onSaveClicked() onSaveClicked()
} }
@@ -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 ->
@@ -0,0 +1,37 @@
package space.hackenslacker.kanbn4droid.app.boards
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class BoardsClickGuardTest {
@Test
fun ignoresBoardClickWhenSettingsOpenIsPending() {
assertTrue(
shouldIgnoreBoardClick(
pendingOpenSettingsAfterDrawerClose = true,
isDrawerOpen = false,
),
)
}
@Test
fun ignoresBoardClickWhenDrawerIsOpen() {
assertTrue(
shouldIgnoreBoardClick(
pendingOpenSettingsAfterDrawerClose = false,
isDrawerOpen = true,
),
)
}
@Test
fun allowsBoardClickWhenNoSettingsOpenIsPendingAndDrawerClosed() {
assertFalse(
shouldIgnoreBoardClick(
pendingOpenSettingsAfterDrawerClose = false,
isDrawerOpen = false,
),
)
}
}