From b1dce9c518f0e2dd35d72ad3c30e3b5dad78f81f Mon Sep 17 00:00:00 2001 From: QIU Date: Wed, 26 Aug 2026 02:22:00 +0000 Subject: [PATCH] fix(wavelog): the upload count included QSOs sent days ago Two smaller findings from the same audit. Entries already confirmed by the server were added to the success total, so re-running an upload reported "N uploaded" counting contacts that went up days ago. They are skipped and no longer counted. A bulk reply that stored nothing read as an acceptance. `{"imported":0}` has a success status and would have cleared the queue. The count is checked now. Look4Sat posts one QSO per request so this was latent, but it would have become real the moment that changed. The count check deliberately looks only at `imported` and `adif_count`, never `adif_errors`: v1 answers a successful upload with `adif_errors:0` beside `adif_count:1`, and matching the wrong key would have rejected every stored QSO. A probe confirms the six relevant shapes classify correctly. --- .../core/domain/wavelog/WavelogResponse.kt | 11 +++++++ .../core/domain/wavelog/WavelogUploader.kt | 14 +++++---- .../domain/wavelog/WavelogResponseTest.kt | 30 +++++++++++++++++++ 3 files changed, 49 insertions(+), 6 deletions(-) diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogResponse.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogResponse.kt index 1296f1a8..f4cfb6c2 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogResponse.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogResponse.kt @@ -86,9 +86,17 @@ object WavelogResponse { if (lower.contains("\"status\"") && SUCCESS_MARKERS.none { lower.contains(it) }) { return Verdict.Unreadable(text.take(MAX_DETAIL)) } + // A bulk reply that stored nothing is not an acceptance, whatever its status says. Look4Sat + // posts one QSO per request so this is latent today, but `imported:0` reading as success + // would silently clear the queue if that ever changes. + if (importedZero(lower)) return Verdict.Rejected("server imported nothing") return Verdict.Accepted(text.take(MAX_DETAIL)) } + /** Whether a bulk reply reports that no record was stored. */ + private fun importedZero(lower: String): Boolean = + IMPORT_COUNT_KEYS.any { lower.contains("\"" + it + "\":0") } + /** * The server's own explanation, when it gave one. * @@ -146,5 +154,8 @@ object WavelogResponse { /** Whitespace next to a colon or comma, which JSON allows and servers use inconsistently. */ private val AROUND_SEPARATORS = Regex("""\s*(?=[:,])|(?<=[:,])\s*""") + /** Count fields a bulk reply uses to say how many records it stored. */ + private val IMPORT_COUNT_KEYS = listOf("imported", "adif_count") + private val REASON_KEYS = listOf("reason", "message", "error") } diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogUploader.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogUploader.kt index 61581cf9..24f5bb14 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogUploader.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogUploader.kt @@ -76,21 +76,23 @@ class WavelogUploader( // 3. Upload one by one. ADIF gridsquare = counterpart grid (the QSO partner); blank until the scraper lands val entries = queue.all() - var ok = 0 - var fail = 0 + var uploaded = 0 + var failed = 0 var firstError = "" for (qso in entries) { - if (qso.uploaded) { ok++; continue } + // Entries already confirmed by the server are skipped, and NOT counted: adding them to + // the total made a re-run report "N uploaded" for QSOs that went up days ago. + if (qso.uploaded) continue val result = WaveLogApi.postQso(url, apiKey, stationId, qso, qso.gridsquare) if (result is WavelogResult.Success) { - ok++ + uploaded++ queue.markUploaded(qso.id) } else { - fail++ + failed++ if (firstError.isBlank()) firstError = (result as? WavelogResult.Failure)?.message ?: "" } } - return UploadOutcome.Done(ok, fail, UploadOutcome.Reason.COMPLETED, firstError) + return UploadOutcome.Done(uploaded, failed, UploadOutcome.Reason.COMPLETED, firstError) } private suspend fun getStationGrid(url: String, apiKey: String, stationId: String): String? { diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogResponseTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogResponseTest.kt index b5a9ece7..1fbcf4e0 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogResponseTest.kt +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogResponseTest.kt @@ -220,6 +220,36 @@ class WavelogResponseTest { assertTrue("got " + verdict, verdict is WavelogResponse.Verdict.Rejected) } + /** A bulk reply that stored nothing is not an acceptance, whatever its status says. */ + @Test + fun `a reply that imported nothing is a rejection`() { + for (body in listOf("""{"imported":0,"skipped":3}""", + """{"status":"created","adif_count":0,"adif_errors":1,"messages":["bad"]}""")) { + assertTrue( + "must not be accepted: " + body, + WavelogResponse.verdict(200, body) is WavelogResponse.Verdict.Rejected + ) + } + } + + /** + * The count check must not misfire on a real success. v1 answers with `adif_errors:0` next to + * `adif_count:1`, and matching the wrong key would reject every stored QSO. + */ + @Test + fun `adif_errors zero does not look like nothing imported`() { + for (body in listOf( + """{"status":"created","adif_count":1,"adif_errors":0,"messages":[""]}""", + """{"status":"created","adif_errors":0}""", + """{"imported":2,"skipped":1}""" + )) { + assertTrue( + "must still be accepted: " + body, + WavelogResponse.verdict(200, body) is WavelogResponse.Verdict.Accepted + ) + } + } + /** A failure with no explanation still has to say something usable. */ @Test fun `a failure without a reason still reports one`() {