diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/service/RepurchaseBatch.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/service/RepurchaseBatch.kt index 04553ff..ba218df 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/service/RepurchaseBatch.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/service/RepurchaseBatch.kt @@ -28,19 +28,33 @@ class RepurchaseBatch internal constructor(items: List) { } /** Read-only discovery. The callback must use the current device's authenticated history endpoint. + * + * [nowMillis] MUST return the trusted server Date time from the response to that fetch, and throw + * when it is missing or invalid. Never fall back to the phone clock or a previous response's Date: + * the server applies the rolling 30-day cutoff, and a slow phone could accept a truncated batch. + * Tests may inject a synthetic server clock. It is read only after each successful page response. * * Offset overlap is tolerated; a changed head is rejected. This is not a snapshot guarantee for * arbitrary concurrent deletion, reassignment or timestamp edits on the server. */ class RepurchaseBatchDiscovery( private val fetchPage: (RepurchaseHistoryQuery) -> HistoryPage, - private val nowMillis: () -> Long = System::currentTimeMillis, + private val nowMillis: () -> Long, private val maxPages: Int = 200, ) { init { require(maxPages > 0) } fun discover(): RepurchaseBatch { - val startedAt = nowMillis() + var latestServerTime = 0L + fun readWithServerTime(pageNumber: Int): HistoryPage { + val page = readPage(pageNumber) + val serverTime = try { nowMillis() } catch (_: Exception) { + refuse(RepurchaseBatchFailure.INCOMPLETE_BATCH) + } + if (serverTime <= 0) refuse(RepurchaseBatchFailure.INCOMPLETE_BATCH) + latestServerTime = maxOf(latestServerTime, serverTime) + return page + } val seen = linkedMapOf() val batch = mutableListOf() var head: Long? = null @@ -49,7 +63,7 @@ class RepurchaseBatchDiscovery( var foundBoundary = false var reachedEnd = false for (pageNumber in 1..maxPages) { - val page = readPage(pageNumber) + val page = readWithServerTime(pageNumber) if (originalTotal == null) originalTotal = page.total else if (page.total != originalTotal) refuse(RepurchaseBatchFailure.LIST_CHANGED) val entries = parseOrdered(page.items) @@ -82,13 +96,14 @@ class RepurchaseBatchDiscovery( } if (!foundBoundary && !reachedEnd) refuse(RepurchaseBatchFailure.PAGE_LIMIT) - val finalPage = readPage(1) + val finalPage = readWithServerTime(1) val finalHead = parseOrdered(finalPage.items).firstOrNull()?.item?.taskId if (finalHead != head || finalPage.total != originalTotal) refuse(RepurchaseBatchFailure.LIST_CHANGED) if (reachedEnd && previous != null) { // The API applies a rolling 30-day range, not a midnight-based date filter. - // Use the later clock reading because the floor can advance during pagination. - val floorMillis = maxOf(startedAt, nowMillis()) - DAYS * 24L * 60 * 60 * 1000 + // HTTP Date has whole-second precision; include its possible fractional second. + // Preserve the latest observed time if a later response's Date moves backwards. + val floorMillis = latestServerTime + 1000 - DAYS * 24L * 60 * 60 * 1000 val floorSeconds = floorMillis / 1000 - if (floorMillis < 0 && floorMillis % 1000 != 0L) 1 else 0 val floor = Timestamp(floorSeconds, ((floorMillis - floorSeconds * 1000) * 1_000_000).toInt()) if (!previous.time.moreThanSixtySecondsAfter(floor)) refuse(RepurchaseBatchFailure.INCOMPLETE_BATCH) diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/service/RepurchaseBatchTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/service/RepurchaseBatchTest.kt index e235024..8a1c547 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/service/RepurchaseBatchTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/service/RepurchaseBatchTest.kt @@ -96,10 +96,60 @@ class RepurchaseBatchTest { @Test fun nearThirtyDayCutoffCannotProveBatchComplete() { val days30 = 30L * 24 * 60 * 60 - listOf(days30, days30 - 60, days30 + 1).forEach { age -> + listOf(days30, days30 - 60, days30 - 61, days30 + 1).forEach { age -> refused(RepurchaseBatchFailure.INCOMPLETE_BATCH) { discover(listOf(item(1, age))) } } - assertEquals(1, discover(listOf(item(1, days30 - 61))).items.size) + assertEquals(1, discover(listOf(item(1, days30 - 62))).items.size) + } + + @Test fun usesServerResponseClockEvenWhenPhoneIsFiveMinutesSlow() { + val phoneNow = now + val serverNow = phoneNow + 5 * 60 * 1000 + val ageFromPhoneSeconds = 30L * 24 * 60 * 60 - 5 * 60 - 30 + var responseTime: Long? = null + refused(RepurchaseBatchFailure.INCOMPLETE_BATCH) { + RepurchaseBatchDiscovery({ q -> + responseTime = serverNow + HistoryPage(listOf(item(1, ageFromPhoneSeconds)), 1, q.page, 50) + }, { checkNotNull(responseTime) }).discover() + } + } + + @Test fun responseTimeIsNotRequiredBeforeFirstFetch() { + var responseTime: Long? = null + val batch = RepurchaseBatchDiscovery({ q -> + responseTime = now + HistoryPage(listOf(item(1)), 1, q.page, 50) + }, { checkNotNull(responseTime) }).discover() + assertEquals(1, batch.items.size) + } + + @Test fun missingServerResponseClockRefusesWithoutPhoneClockFallback() { + refused(RepurchaseBatchFailure.INCOMPLETE_BATCH) { + RepurchaseBatchDiscovery({ q -> HistoryPage(listOf(item(1)), 1, q.page, 50) }, + { error("No server Date") }).discover() + } + } + + @Test fun retainsLatestObservedServerClockWhenLaterResponseTimeRegresses() { + var responseTime = now + var calls = 0 + refused(RepurchaseBatchFailure.INCOMPLETE_BATCH) { + RepurchaseBatchDiscovery({ q -> + responseTime = if (++calls == 1) now + 300_000 else now + HistoryPage(listOf(item(1, 30L * 24 * 60 * 60 - 330)), 1, q.page, 50) + }, { responseTime }).discover() + } + } + + @Test fun exactlyFiftyRecordsCanEstablishTrustworthyEnd() { + val requested = mutableListOf() + val batch = RepurchaseBatchDiscovery({ q -> + requested += q.page + HistoryPage((50L downTo 1).map { item(it, 50 - it) }, 50, q.page, 50) + }, { now }).discover() + assertEquals(50, batch.items.size) + assertEquals(listOf(1, 1), requested) } @Test fun latestSuccessfulBatchDoesNotFallBackToOlderFailures() {