diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java index 282ae7b0a..2c0595f9e 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java @@ -139,6 +139,7 @@ import radio.ks3ckc.ft8af.UsbPermissionIntentsKt; import radio.ks3ckc.ft8af.pskreporter.PskReporterSender; +import radio.ks3ckc.ft8af.sync.CloudlogFailureHint; import radio.ks3ckc.ft8af.wsjtx.WsjtxCodec; import radio.ks3ckc.ft8af.wsjtx.WsjtxUdpService; @@ -1127,7 +1128,16 @@ public void run() { boolean qrzOk = false; boolean wrlOk = false; if (GeneralVariables.enableCloudlog){ - cloudlogOk = ThirdPartyService.UploadToCloudLog(qslRecord); + StringBuilder why = new StringBuilder(); + cloudlogOk = ThirdPartyService.UploadToCloudLog(qslRecord, why); + if (!cloudlogOk && why.length() > 0) { + // Say why on screen, not just in debug.log: a Station ID + // from the wrong Wavelog user passes Test Connection and + // then rejects every QSO with no visible symptom. + ToastMessage.show(String.format( + getStringFromResource(R.string.cloudlog_upload_rejected), + CloudlogFailureHint.cloudlogFailureMessage(why.toString()))); + } } if (GeneralVariables.enableQRZ){ qrzOk = ThirdPartyService.UploadToQRZ(qslRecord); diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/log/ThirdPartyService.java b/ft8af/app/src/main/java/com/k1af/ft8af/log/ThirdPartyService.java index c7dc10d73..e654684ec 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/log/ThirdPartyService.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/log/ThirdPartyService.java @@ -312,9 +312,18 @@ static String QSLRecordToADIF(QSLRecord qslRecord, ServiceType serv){ return logStr.toString(); } public static boolean UploadToCloudLog(QSLRecord qslRecord){ + return UploadToCloudLog(qslRecord, null); + } + + /** + * As {@link #UploadToCloudLog(QSLRecord)}, but records why the upload failed into + * {@code failureOut} so the post-QSO path can toast the server's reason instead of + * failing silently. + */ + public static boolean UploadToCloudLog(QSLRecord qslRecord, StringBuilder failureOut){ // Convert to ADIF format String logStr = QSLRecordToADIF(qslRecord,ServiceType.Cloudlog); - return uploadAdifToCloudlog(logStr); + return uploadAdifToCloudlog(logStr, failureOut); } /** @@ -345,6 +354,13 @@ public static boolean uploadAdifToCloudlog(String adif, StringBuilder failureOut // HttpURLConnection won't follow on a POST. String clRes = cloudlogPost(base, "api/qso", result, failureOut); Log.d(TAG, "Cloudlog upload " + (clRes != null ? "succeeded" : "failed")); + if (clRes == null) { + // The station id is the one request field the server's reason can point + // at ("station id does not belong to the API key owner") and the one thing + // the POST log line above doesn't show. Not a secret, so log it plainly. + GeneralVariables.fileLog("Cloudlog: upload rejected with station_profile_id=" + + describeStationId(GeneralVariables.getCloudlogStationID())); + } return clRes != null; }catch (Exception k){ Log.d(TAG, "Cloudlog upload error: " + k.getClass().getSimpleName()); @@ -949,6 +965,12 @@ static String sendPostRequest(String url, String json, StringBuilder failureOut, return null; } + /** The configured station id for a log line: "(blank)" when unset, since "" vanishes. */ + static String describeStationId(String stationId) { + if (stationId == null || stationId.trim().isEmpty()) return "(blank)"; + return stationId.trim(); + } + private static void appendFailure(StringBuilder failureOut, String reason) { if (failureOut == null || reason == null || reason.isEmpty()) return; if (failureOut.length() > 0) failureOut.append("; "); diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/sync/CloudlogFailureHint.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/sync/CloudlogFailureHint.kt new file mode 100644 index 000000000..175bd9e79 --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/sync/CloudlogFailureHint.kt @@ -0,0 +1,94 @@ +@file:JvmName("CloudlogFailureHint") + +package radio.ks3ckc.ft8af.sync + +import com.k1af.ft8af.GeneralVariables +import com.k1af.ft8af.R + +/** + * Turns a rejected Cloudlog / Wavelog / Nextlog upload into something the operator can act on. + * + * The failure line that reaches us (see `ThirdPartyService.describeHttpFailure`) is the raw + * HTTP status plus the server's JSON body, e.g. + * + * HTTP 401: {"status":"failed","reason":"station id does not belong to the API key owner."} + * + * That line is exactly right for `debug.log`, but on screen the JSON wrapper is noise and the + * reason alone still leaves the operator guessing which setting to fix. The two Wavelog + * rejections that account for nearly every "the test passes but nothing uploads" support + * thread are a Station ID that isn't the key owner's and a key without write rights, so those + * get a one-line hint pointing at the setting. Pure string logic, unit-tested; the only + * Android touch-point is [cloudlogFailureMessage] resolving string resources. + */ +enum class CloudlogFailureKind { + /** The `station_profile_id` is blank or belongs to another Wavelog user. */ + STATION_ID, + + /** The API key is missing, unknown, disabled, or read-only. */ + API_KEY, + + /** Anything else: transport errors, ADIF import errors, proxies returning HTML… */ + OTHER, +} + +private val REASON = Regex("\"reason\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"") +private val MESSAGES = Regex("\"messages\"\\s*:\\s*\\[(.*?)]") +private val QUOTED = Regex("\"((?:[^\"\\\\]|\\\\.)*)\"") + +private fun unescapeJson(s: String): String { + return s.replace("\\\"", "\"").replace("\\/", "/").replace("\\\\", "\\") +} + +/** + * The server's own explanation, stripped of the HTTP/JSON wrapper: the `reason` field when + * there is one, else the non-empty `messages` entries joined with "; ", else the line as-is + * (a bare "HTTP 502" or "Unknown host: …" is already readable). Blank in, blank out. + */ +fun cloudlogServerReason(failure: String?): String { + val raw = failure?.trim().orEmpty() + if (raw.isEmpty()) return "" + REASON.find(raw)?.let { return unescapeJson(it.groupValues[1]).trim() } + MESSAGES.find(raw)?.let { m -> + val parts = + QUOTED.findAll(m.groupValues[1]) + .map { unescapeJson(it.groupValues[1]).trim() } + .filter { it.isNotEmpty() } + .toList() + if (parts.isNotEmpty()) return parts.joinToString("; ") + } + return raw +} + +/** Which setting the failure points at. Station check runs first: its text mentions the key too. */ +fun classifyCloudlogFailure(failure: String?): CloudlogFailureKind { + val reason = cloudlogServerReason(failure).lowercase() + if (reason.isEmpty()) return CloudlogFailureKind.OTHER + if (reason.contains("station")) return CloudlogFailureKind.STATION_ID + if (reason.contains("api key") || reason.contains("apikey") || reason.contains("key invalid")) { + return CloudlogFailureKind.API_KEY + } + return CloudlogFailureKind.OTHER +} + +/** String resource for the fix-it hint, or null when we have nothing better than the reason. */ +fun cloudlogHintResId(failure: String?): Int? = + when (classifyCloudlogFailure(failure)) { + CloudlogFailureKind.STATION_ID -> R.string.cloudlog_hint_station_id + CloudlogFailureKind.API_KEY -> R.string.cloudlog_hint_api_key + CloudlogFailureKind.OTHER -> null + } + +/** + * One sentence for a toast: the server's reason followed by the hint when there is one. + * Resolves resources through [GeneralVariables.getStringFromResource] so it is callable from + * the Java upload thread in MainViewModel as well as from Kotlin. + */ +fun cloudlogFailureMessage(failure: String?): String { + val reason = cloudlogServerReason(failure) + val hint = cloudlogHintResId(failure)?.let { GeneralVariables.getStringFromResource(it) }.orEmpty() + return when { + hint.isEmpty() -> reason + reason.isEmpty() -> hint + else -> "$reason $hint" + } +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/sync/QsoAutoSync.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/sync/QsoAutoSync.kt index 4df6da2aa..a5e71115c 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/sync/QsoAutoSync.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/sync/QsoAutoSync.kt @@ -6,8 +6,11 @@ import android.net.Network import android.net.NetworkCapabilities import android.net.NetworkRequest import android.os.SystemClock +import com.k1af.ft8af.GeneralVariables +import com.k1af.ft8af.R import com.k1af.ft8af.database.DatabaseOpr import com.k1af.ft8af.log.ThirdPartyService +import com.k1af.ft8af.ui.ToastMessage import java.io.File import java.text.SimpleDateFormat import java.util.Date @@ -170,6 +173,14 @@ class QsoAutoSync(private val appContext: Context) { log("start ($reason): $pending pending") val result = ThirdPartyService.syncAllQSOs(db, null) log("done ($reason): " + summarize(result)) + // The operator never pressed anything for this run, so the only way they + // learn their logbook server is rejecting QSOs is if we say so here. + result.cloudlogError?.takeIf { it.isNotBlank() }?.let { err -> + ToastMessage.show( + GeneralVariables.getStringFromResource(R.string.cloudlog_upload_rejected) + .format(cloudlogFailureMessage(err)), + ) + } } catch (e: Exception) { log("error ($reason): ${e.javaClass.simpleName} ${e.message}") } finally { diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/logbook/LogbookScreen.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/logbook/LogbookScreen.kt index 92c59b738..b2632c149 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/logbook/LogbookScreen.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/logbook/LogbookScreen.kt @@ -95,6 +95,8 @@ import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext +import radio.ks3ckc.ft8af.sync.cloudlogHintResId +import radio.ks3ckc.ft8af.sync.cloudlogServerReason import radio.ks3ckc.ft8af.theme.* import radio.ks3ckc.ft8af.ui.components.AnimatedCounter import radio.ks3ckc.ft8af.ui.components.EmptyStateWaves @@ -428,6 +430,9 @@ fun LogbookScreen(mainViewModel: MainViewModel) { cloudlogOk = result?.cloudlogOk ?: 0, qrzOk = result?.qrzOk ?: 0, wrlOk = result?.wrlOk ?: 0, + cloudlogError = result?.cloudlogError, + qrzError = result?.qrzError, + wrlError = result?.wrlError, ) // Re-query QSLTable so the row chips pick up the // newly-set synced_cloudlog / synced_qrz / synced_wrl flags. @@ -577,6 +582,10 @@ private data class SyncDialogState( val wrlAttempted: Boolean, val finished: Boolean, val noServicesEnabled: Boolean, + /** Why the first failed upload failed, per service (null = none failed). Shown when finished. */ + val cloudlogError: String? = null, + val qrzError: String? = null, + val wrlError: String? = null, ) @Composable @@ -2463,6 +2472,13 @@ private fun CatchUpSyncDialog( fontSize = 12.sp, ) } + // "0 accepted" with no reason is what sends people to Discord. Show the + // server's own explanation, plus which setting to look at when we know. + if (state.finished) { + SyncRejection(R.string.log_sync_cloudlog_rejected, state.cloudlogError, hint = true) + SyncRejection(R.string.log_sync_qrz_rejected, state.qrzError, hint = false) + SyncRejection(R.string.log_sync_wrl_rejected, state.wrlError, hint = false) + } } Spacer(modifier = Modifier.height(20.dp)) @@ -2489,6 +2505,37 @@ private fun CatchUpSyncDialog( } } +/** + * One rejected-upload line in the catch-up dialog: the service's reason in red, and for + * Cloudlog-family servers the fix-it hint (Station ID / API key) underneath. Renders + * nothing when the service had no failure. + */ +@Composable +private fun SyncRejection( + @StringRes labelRes: Int, + failure: String?, + hint: Boolean, +) { + if (failure.isNullOrBlank()) return + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(labelRes, cloudlogServerReason(failure)), + color = StatusBad, + fontSize = 12.sp, + lineHeight = 16.sp, + ) + val hintRes = if (hint) cloudlogHintResId(failure) else null + if (hintRes != null) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(hintRes), + color = TextMuted, + fontSize = 12.sp, + lineHeight = 16.sp, + ) + } +} + @Composable private fun AdifImportDialog( state: AdifImportUiState, diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/LoggingSettings.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/LoggingSettings.kt index f382e9f6f..eca263c43 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/LoggingSettings.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/LoggingSettings.kt @@ -666,6 +666,25 @@ private fun CloudlogSettingsDialog( modifier = Modifier.fillMaxWidth(), ) } + // A passing test says nothing about the Station ID, and Wavelog rejects every + // QSO whose station isn't the key owner's. Once the server has answered, warn + // when the entered ID is blank or not in the list it returned for this key. + val serverAnswered = testResult == true || stationList.isNotEmpty() + val stationWarning = + when (checkStationId(stationIdInput.text, stationList)) { + StationIdCheck.BLANK -> R.string.settings_station_id_missing + StationIdCheck.NOT_ON_SERVER -> R.string.settings_station_id_not_on_server + else -> null + } + if (serverAnswered && stationWarning != null) { + Text( + text = stringResource(stationWarning, stationIdInput.text.trim()), + color = StatusBad, + fontSize = 12.sp, + lineHeight = 16.sp, + modifier = Modifier.fillMaxWidth(), + ) + } Row( modifier = Modifier.fillMaxWidth(), diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/StationIdCheck.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/StationIdCheck.kt new file mode 100644 index 000000000..378398e41 --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/StationIdCheck.kt @@ -0,0 +1,38 @@ +package radio.ks3ckc.ft8af.ui.settings + +import com.k1af.ft8af.log.ThirdPartyService + +/** + * Does the Station ID in the Cloudlog / Wavelog dialog stand a chance of being accepted? + * + * Test Connection only proves the API key (`api/auth`), and the station list it fetches + * afterwards is scoped to that key's owner. Wavelog rejects every QSO whose + * `station_profile_id` is blank or belongs to a different user with + * "station id does not belong to the API key owner." — so a Station ID that is blank, or + * that doesn't appear in the fetched list, is going to fail before the operator ever + * leaves Settings. Pure so it is unit-testable; the composable just maps the result to a + * warning string. + */ +enum class StationIdCheck { + /** The ID is one of the key owner's stations. */ + OK, + + /** No ID entered at all. */ + BLANK, + + /** The server listed stations for this key and this ID is not among them. */ + NOT_ON_SERVER, + + /** No station list to judge against (not fetched yet, or the key owner has none). */ + UNKNOWN, +} + +internal fun checkStationId( + stationId: String, + stations: List, +): StationIdCheck { + val id = stationId.trim() + if (id.isEmpty()) return StationIdCheck.BLANK + if (stations.isEmpty()) return StationIdCheck.UNKNOWN + return if (stations.any { it.stationId == id }) StationIdCheck.OK else StationIdCheck.NOT_ON_SERVER +} diff --git a/ft8af/app/src/main/res/values/strings_compose.xml b/ft8af/app/src/main/res/values/strings_compose.xml index 4978e8c07..fd673e419 100644 --- a/ft8af/app/src/main/res/values/strings_compose.xml +++ b/ft8af/app/src/main/res/values/strings_compose.xml @@ -376,6 +376,14 @@ World Radio League: %1$d accepted No QSOs in the logbook to upload yet. Working… + + Cloudlog / Wavelog / Nextlog rejected uploads: %1$s + QRZ rejected uploads: %1$s + World Radio League rejected uploads: %1$s + + Cloudlog / Wavelog rejected the QSO: %1$s + Check the Station ID in Settings › Logging › Cloudlog / Wavelog: tap Test Connection and pick it from the list. It must belong to the same user that created the API key. + Check the API key in Settings › Logging › Cloudlog / Wavelog. It must be enabled and have read/write rights. Import ADIF log IMPORTING ADIF LOG… IMPORT COMPLETE @@ -996,6 +1004,8 @@ Loading stations... Station ID e.g. 1 + No Station ID selected. Pick one from the list or every upload will be rejected. + Station ID %1$s is not one of this API key\'s stations. Pick one from the list. Select a station Enter manually Choose from server diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/log/ThirdPartyServiceFailureReasonTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/log/ThirdPartyServiceFailureReasonTest.java index cdcb0c2db..68a6b8e98 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/log/ThirdPartyServiceFailureReasonTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/log/ThirdPartyServiceFailureReasonTest.java @@ -55,6 +55,13 @@ public void httpFailure_truncatesAnEnormousBody() { assertThat(out).endsWith("…"); } + @Test + public void stationId_forLogLine_namesBlankExplicitly() { + assertThat(ThirdPartyService.describeStationId(null)).isEqualTo("(blank)"); + assertThat(ThirdPartyService.describeStationId(" ")).isEqualTo("(blank)"); + assertThat(ThirdPartyService.describeStationId(" 4 ")).isEqualTo("4"); + } + @Test public void qrzFailure_reportsResultAndReason() { String out = ThirdPartyService.describeQrzFailure( diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/sync/CloudlogFailureHintTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/sync/CloudlogFailureHintTest.kt new file mode 100644 index 000000000..55427eac1 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/sync/CloudlogFailureHintTest.kt @@ -0,0 +1,85 @@ +package radio.ks3ckc.ft8af.sync + +import com.google.common.truth.Truth.assertThat +import com.k1af.ft8af.R +import org.junit.Test + +/** + * Unit tests for the rejected-upload explainer: [cloudlogServerReason], [classifyCloudlogFailure] + * and [cloudlogHintResId]. + * + * The motivating case is a Wavelog user whose Test Connection passes but every QSO comes + * back `HTTP 401: {"status":"failed","reason":"station id does not belong to the API key + * owner."}` — a wrong Station ID. The app used to show "0 accepted" and nothing else. + */ +class CloudlogFailureHintTest { + private val stationRejection = + "HTTP 401: {\"status\":\"failed\",\"reason\":\"station id does not belong to the API key owner.\"}" + + @Test + fun `wavelog station rejection yields the bare reason and the station hint`() { + assertThat(cloudlogServerReason(stationRejection)) + .isEqualTo("station id does not belong to the API key owner.") + assertThat(classifyCloudlogFailure(stationRejection)).isEqualTo(CloudlogFailureKind.STATION_ID) + assertThat(cloudlogHintResId(stationRejection)).isEqualTo(R.string.cloudlog_hint_station_id) + } + + @Test + fun `wrong api key points at the key, not the station`() { + val f = "HTTP 401: {\"status\":\"failed\",\"reason\":\"missing or wrong api key\"}" + + assertThat(cloudlogServerReason(f)).isEqualTo("missing or wrong api key") + assertThat(classifyCloudlogFailure(f)).isEqualTo(CloudlogFailureKind.API_KEY) + assertThat(cloudlogHintResId(f)).isEqualTo(R.string.cloudlog_hint_api_key) + } + + @Test + fun `read-only key is an api key problem`() { + val f = "HTTP 403: {\"status\":\"failed\",\"reason\":\"API key does not have write permissions\"}" + + assertThat(classifyCloudlogFailure(f)).isEqualTo(CloudlogFailureKind.API_KEY) + } + + @Test + fun `adif import errors surface the messages array with no hint`() { + val f = + "HTTP 400: {\"status\":\"abort\",\"adif_errors\":1," + + "\"messages\":[\"column \\\"tx_pwr\\\" of relation \\\"contacts\\\" does not exist\", \"\"]}" + + assertThat(cloudlogServerReason(f)) + .isEqualTo("column \"tx_pwr\" of relation \"contacts\" does not exist") + assertThat(classifyCloudlogFailure(f)).isEqualTo(CloudlogFailureKind.OTHER) + assertThat(cloudlogHintResId(f)).isNull() + } + + @Test + fun `several messages are joined`() { + val f = "HTTP 400: {\"status\":\"abort\",\"messages\":[\"first\",\"second\"]}" + + assertThat(cloudlogServerReason(f)).isEqualTo("first; second") + } + + @Test + fun `non-json failures pass through unchanged`() { + assertThat(cloudlogServerReason("HTTP 502")).isEqualTo("HTTP 502") + assertThat(cloudlogServerReason("Unknown host: wavelog.example")).isEqualTo("Unknown host: wavelog.example") + assertThat(cloudlogServerReason("HTTP 500: gateway timeout")) + .isEqualTo("HTTP 500: gateway timeout") + assertThat(classifyCloudlogFailure("HTTP 502")).isEqualTo(CloudlogFailureKind.OTHER) + assertThat(cloudlogHintResId("Unknown host: wavelog.example")).isNull() + } + + @Test + fun `blank and null are harmless`() { + assertThat(cloudlogServerReason(null)).isEmpty() + assertThat(cloudlogServerReason(" ")).isEmpty() + assertThat(classifyCloudlogFailure(null)).isEqualTo(CloudlogFailureKind.OTHER) + assertThat(cloudlogHintResId("")).isNull() + } + + @Test + fun `station check wins even though its text also mentions the api key`() { + // "…does not belong to the API key owner" contains "api key"; the fix is still the station. + assertThat(classifyCloudlogFailure(stationRejection)).isEqualTo(CloudlogFailureKind.STATION_ID) + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/settings/StationIdCheckTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/settings/StationIdCheckTest.kt new file mode 100644 index 000000000..05d524c42 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/settings/StationIdCheckTest.kt @@ -0,0 +1,42 @@ +package radio.ks3ckc.ft8af.ui.settings + +import com.google.common.truth.Truth.assertThat +import com.k1af.ft8af.log.ThirdPartyService +import org.junit.Test + +/** + * Unit tests for [checkStationId], the Station ID sanity check under Test Connection in the + * Cloudlog / Wavelog settings dialog. Wavelog rejects every QSO whose station isn't the API + * key owner's, and Test Connection alone can't tell — so the dialog warns up front. + */ +class StationIdCheckTest { + private fun station(id: String) = ThirdPartyService.StationProfile(id, "Home", "W1AW", "FN31") + + @Test + fun `id in the fetched list is ok`() { + assertThat(checkStationId("2", listOf(station("1"), station("2")))) + .isEqualTo(StationIdCheck.OK) + } + + @Test + fun `id trimmed before matching`() { + assertThat(checkStationId(" 2 \n", listOf(station("2")))).isEqualTo(StationIdCheck.OK) + } + + @Test + fun `id missing from the list is flagged`() { + assertThat(checkStationId("7", listOf(station("1"), station("2")))) + .isEqualTo(StationIdCheck.NOT_ON_SERVER) + } + + @Test + fun `blank id is flagged regardless of the list`() { + assertThat(checkStationId("", listOf(station("1")))).isEqualTo(StationIdCheck.BLANK) + assertThat(checkStationId(" ", emptyList())).isEqualTo(StationIdCheck.BLANK) + } + + @Test + fun `no list means no verdict`() { + assertThat(checkStationId("3", emptyList())).isEqualTo(StationIdCheck.UNKNOWN) + } +}