From ba01fab7656bf28fa0d0311810c8f3e88acec016 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Tue, 13 Oct 2026 09:37:18 -0500 Subject: [PATCH] Surface Cloudlog/Wavelog upload rejections to the operator A Wavelog user whose Station ID belongs to a different user than the API key passes Test Connection and then has every QSO rejected with HTTP 401 "station id does not belong to the API key owner." The app showed "0 accepted" and nothing else; the reason only lived in debug.log. - Catch-up sync dialog: show each service's rejection reason (JSON wrapper stripped) and, for Cloudlog-family servers, a hint naming the setting to fix (Station ID / API key). - Post-QSO upload and background auto-sync: toast the same reason + hint. - Cloudlog settings dialog: after Test Connection, warn when the entered Station ID is blank or not in the station list the server returned for this key. - debug.log: log the station_profile_id on every rejected upload. New pure helpers (CloudlogFailureHint.kt, StationIdCheck.kt) with unit tests; verified on the emulator against a mock Wavelog. Co-Authored-By: Claude Fable 5.1 --- .../java/com/k1af/ft8af/MainViewModel.java | 12 ++- .../com/k1af/ft8af/log/ThirdPartyService.java | 24 ++++- .../ks3ckc/ft8af/sync/CloudlogFailureHint.kt | 94 +++++++++++++++++++ .../radio/ks3ckc/ft8af/sync/QsoAutoSync.kt | 11 +++ .../ks3ckc/ft8af/ui/logbook/LogbookScreen.kt | 47 ++++++++++ .../ft8af/ui/settings/LoggingSettings.kt | 19 ++++ .../ft8af/ui/settings/StationIdCheck.kt | 38 ++++++++ .../src/main/res/values/strings_compose.xml | 10 ++ .../ThirdPartyServiceFailureReasonTest.java | 7 ++ .../ft8af/sync/CloudlogFailureHintTest.kt | 85 +++++++++++++++++ .../ft8af/ui/settings/StationIdCheckTest.kt | 42 +++++++++ 11 files changed, 387 insertions(+), 2 deletions(-) create mode 100644 ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/sync/CloudlogFailureHint.kt create mode 100644 ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/StationIdCheck.kt create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/sync/CloudlogFailureHintTest.kt create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/settings/StationIdCheckTest.kt 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) + } +}