Compare commits
7 Commits
b6da868103
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| cdb8ba58da | |||
| de59a1d0e7 | |||
| d162841708 | |||
| 29e859bc01 | |||
| 1db5c53375 | |||
| 784f92bd40 | |||
| 11a368b7c8 |
@@ -59,6 +59,7 @@ dependencies {
|
|||||||
|
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
testImplementation(libs.kotlinx.coroutines.test)
|
testImplementation(libs.kotlinx.coroutines.test)
|
||||||
|
testImplementation("org.json:json:20240303")
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
androidTestImplementation(libs.androidx.espresso.contrib)
|
androidTestImplementation(libs.androidx.espresso.contrib)
|
||||||
|
|||||||
@@ -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) {
|
if (canonicalResult is BoardsApiResult.Success) {
|
||||||
payloadFields += "\"dueDate\":\"${dueDate}T00:00:00Z\""
|
return@withContext canonicalResult
|
||||||
}
|
}
|
||||||
val payload = "{${payloadFields.joinToString(",")}}"
|
|
||||||
|
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,
|
||||||
@@ -915,6 +975,9 @@ class HttpKanbnApiClient : KanbnApiClient {
|
|||||||
val boards = mutableListOf<BoardSummary>()
|
val boards = mutableListOf<BoardSummary>()
|
||||||
for (index in 0 until array.length()) {
|
for (index in 0 until array.length()) {
|
||||||
val item = array.optJSONObject(index) ?: continue
|
val item = array.optJSONObject(index) ?: continue
|
||||||
|
if (isTemplateBoard(item)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
val id = item.opt("id")?.toString().orEmpty().ifBlank {
|
val id = item.opt("id")?.toString().orEmpty().ifBlank {
|
||||||
item.optString("publicId")
|
item.optString("publicId")
|
||||||
.ifBlank { item.optString("public_id") }
|
.ifBlank { item.optString("public_id") }
|
||||||
@@ -931,6 +994,15 @@ class HttpKanbnApiClient : KanbnApiClient {
|
|||||||
return boards
|
return boards
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun isTemplateBoard(item: JSONObject): Boolean {
|
||||||
|
val typeKeys = listOf("type", "boardType", "kind")
|
||||||
|
return typeKeys.any { key ->
|
||||||
|
item.optString(key)
|
||||||
|
.trim()
|
||||||
|
.equals("template", ignoreCase = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun parseSingleBoard(body: String, fallbackName: String): BoardSummary {
|
private fun parseSingleBoard(body: String, fallbackName: String): BoardSummary {
|
||||||
if (body.isBlank()) {
|
if (body.isBlank()) {
|
||||||
return BoardSummary(id = "new", title = fallbackName)
|
return BoardSummary(id = "new", title = fallbackName)
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package space.hackenslacker.kanbn4droid.app.boards
|
||||||
|
|
||||||
|
internal fun shouldIgnoreBoardClick(
|
||||||
|
pendingOpenSettingsAfterDrawerClose: Boolean,
|
||||||
|
isDrawerOpen: Boolean,
|
||||||
|
): Boolean {
|
||||||
|
return pendingOpenSettingsAfterDrawerClose || isDrawerOpen
|
||||||
|
}
|
||||||
@@ -93,11 +93,30 @@ class BoardsRepository(
|
|||||||
is BoardsApiResult.Success -> sessionResult.value
|
is BoardsApiResult.Success -> sessionResult.value
|
||||||
is BoardsApiResult.Failure -> return sessionResult
|
is BoardsApiResult.Failure -> return sessionResult
|
||||||
}
|
}
|
||||||
return apiClient.listBoards(
|
val boardsResult = apiClient.listBoards(
|
||||||
baseUrl = session.baseUrl,
|
baseUrl = session.baseUrl,
|
||||||
apiKey = session.apiKey,
|
apiKey = session.apiKey,
|
||||||
workspaceId = session.workspaceId,
|
workspaceId = session.workspaceId,
|
||||||
)
|
)
|
||||||
|
if (boardsResult is BoardsApiResult.Failure) {
|
||||||
|
return boardsResult
|
||||||
|
}
|
||||||
|
|
||||||
|
val boards = (boardsResult as BoardsApiResult.Success).value
|
||||||
|
val templatesResult = apiClient.listBoardTemplates(
|
||||||
|
baseUrl = session.baseUrl,
|
||||||
|
apiKey = session.apiKey,
|
||||||
|
workspaceId = session.workspaceId,
|
||||||
|
)
|
||||||
|
|
||||||
|
return when (templatesResult) {
|
||||||
|
is BoardsApiResult.Success -> {
|
||||||
|
val templateIds = templatesResult.value.map { it.id }.toSet()
|
||||||
|
BoardsApiResult.Success(boards.filterNot { it.id in templateIds })
|
||||||
|
}
|
||||||
|
|
||||||
|
is BoardsApiResult.Failure -> BoardsApiResult.Success(boards)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun listTemplates(): BoardsApiResult<List<BoardTemplate>> {
|
suspend fun listTemplates(): BoardsApiResult<List<BoardTemplate>> {
|
||||||
|
|||||||
+9
-8
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-3
@@ -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 ->
|
||||||
|
|||||||
+304
@@ -0,0 +1,304 @@
|
|||||||
|
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.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.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import space.hackenslacker.kanbn4droid.app.boards.BoardSummary
|
||||||
|
import space.hackenslacker.kanbn4droid.app.boards.BoardTemplate
|
||||||
|
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
|
||||||
|
|
||||||
|
class HttpKanbnApiClientBoardsParsingTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun listBoards_filtersTemplateEntries_whenPayloadContainsMixedBoardTypes() = runTest {
|
||||||
|
TestServer().use { server ->
|
||||||
|
server.register(
|
||||||
|
path = "/api/v1/workspaces/ws-1/boards",
|
||||||
|
method = "GET",
|
||||||
|
status = 200,
|
||||||
|
responseBody =
|
||||||
|
"""
|
||||||
|
[
|
||||||
|
{"id":"board-1","name":"Roadmap"},
|
||||||
|
{"id":"tmpl-1","name":"Template A","type":"template"},
|
||||||
|
{"id":"tmpl-2","name":"Template B","kind":"template"},
|
||||||
|
{"id":"tmpl-4","name":"Template D","boardType":" Template "},
|
||||||
|
{"publicId":"board-2","title":"Backlog"}
|
||||||
|
]
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = HttpKanbnApiClient().listBoards(server.baseUrl, "api", "ws-1")
|
||||||
|
|
||||||
|
val boards = requireBoards(result)
|
||||||
|
assertEquals(listOf("board-1", "board-2"), boards.map { it.id })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun listBoards_returnsEmpty_whenPayloadContainsOnlyTemplates() = runTest {
|
||||||
|
TestServer().use { server ->
|
||||||
|
server.register(
|
||||||
|
path = "/api/v1/workspaces/ws-1/boards",
|
||||||
|
method = "GET",
|
||||||
|
status = 200,
|
||||||
|
responseBody =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"boards": [
|
||||||
|
{"id":"tmpl-1","name":"Template A","type":"template"},
|
||||||
|
{"id":"tmpl-2","name":"Template B","kind":"template"},
|
||||||
|
{"id":"tmpl-3","name":"Template C","boardType":"TEMPLATE"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = HttpKanbnApiClient().listBoards(server.baseUrl, "api", "ws-1")
|
||||||
|
|
||||||
|
val boards = requireBoards(result)
|
||||||
|
assertTrue(boards.isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun listBoardTemplates_stillReturnsTemplates_fromTemplateEndpoint() = runTest {
|
||||||
|
TestServer().use { server ->
|
||||||
|
server.register(
|
||||||
|
path = "/api/v1/workspaces/ws-1/boards?type=template",
|
||||||
|
method = "GET",
|
||||||
|
status = 200,
|
||||||
|
responseBody =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"templates": [
|
||||||
|
{"id":"tmpl-1","name":"Template A","type":"template"},
|
||||||
|
{"public_id":"tmpl-2","title":"Template B","kind":"template"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = HttpKanbnApiClient().listBoardTemplates(server.baseUrl, "api", "ws-1")
|
||||||
|
|
||||||
|
val templates = requireTemplates(result)
|
||||||
|
assertEquals(listOf("tmpl-1", "tmpl-2"), templates.map { it.id })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun listBoards_keepsRegularBoardsUnchanged_whenNoTemplateMarkersExist() = runTest {
|
||||||
|
TestServer().use { server ->
|
||||||
|
server.register(
|
||||||
|
path = "/api/v1/workspaces/ws-1/boards",
|
||||||
|
method = "GET",
|
||||||
|
status = 200,
|
||||||
|
responseBody =
|
||||||
|
"""
|
||||||
|
[
|
||||||
|
{"id":"board-a","title":"A"},
|
||||||
|
{"publicId":"board-b","name":"B"}
|
||||||
|
]
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = HttpKanbnApiClient().listBoards(server.baseUrl, "api", "ws-1")
|
||||||
|
|
||||||
|
val boards = requireBoards(result)
|
||||||
|
assertEquals(
|
||||||
|
listOf(
|
||||||
|
BoardSummary(id = "board-a", title = "A"),
|
||||||
|
BoardSummary(id = "board-b", title = "B"),
|
||||||
|
),
|
||||||
|
boards,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requireBoards(result: BoardsApiResult<List<BoardSummary>>): List<BoardSummary> {
|
||||||
|
return when (result) {
|
||||||
|
is BoardsApiResult.Success -> result.value
|
||||||
|
is BoardsApiResult.Failure -> throw AssertionError("Expected boards success, got failure: ${result.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requireTemplates(result: BoardsApiResult<List<BoardTemplate>>): List<BoardTemplate> {
|
||||||
|
return when (result) {
|
||||||
|
is BoardsApiResult.Success -> result.value
|
||||||
|
is BoardsApiResult.Failure -> throw AssertionError("Expected templates success, got failure: ${result.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 responseSequences = mutableMapOf<String, ArrayDeque<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) {
|
||||||
|
register(path = path, method = "GET", status = status, responseBody = responseBody)
|
||||||
|
register(path = path, method = "PATCH", status = status, responseBody = responseBody)
|
||||||
|
register(path = path, method = "PUT", status = status, responseBody = responseBody)
|
||||||
|
register(path = path, method = "DELETE", status = status, responseBody = responseBody)
|
||||||
|
register(path = path, method = "POST", status = status, responseBody = responseBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun register(path: String, method: String, status: Int, responseBody: String) {
|
||||||
|
responses["${method.uppercase()} $path"] = status to responseBody
|
||||||
|
}
|
||||||
|
|
||||||
|
fun registerSequence(path: String, method: String, responses: List<Pair<Int, String>>) {
|
||||||
|
responseSequences["${method.uppercase()} $path"] = ArrayDeque(responses)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 sequenceKey = "$effectiveMethod $path"
|
||||||
|
val sequence = responseSequences[sequenceKey]
|
||||||
|
val sequencedResponse = if (sequence != null && sequence.isNotEmpty()) sequence.removeFirst() else null
|
||||||
|
val response = sequencedResponse ?: responses[sequenceKey] ?: 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"
|
||||||
|
403 -> "Forbidden"
|
||||||
|
409 -> "Conflict"
|
||||||
|
404 -> "Not Found"
|
||||||
|
500 -> "Internal Server Error"
|
||||||
|
502 -> "Bad Gateway"
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-3
@@ -189,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"))
|
||||||
@@ -226,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(
|
||||||
|
|||||||
Reference in New Issue
Block a user