feat(t206): connect Android procurement tasks

This commit is contained in:
QiuSW
2026-07-27 11:11:16 +08:00
parent 9e698c1b6c
commit 45b66eeea7
35 changed files with 2634 additions and 81 deletions
@@ -0,0 +1,51 @@
package com.roubao.autopilot.procurement
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class BackendEndpointPolicyTest {
@Test
fun releaseAcceptsOnlyHttps() {
assertEquals(
"https://admin.example.com:8443",
BackendEndpointPolicy.normalize(
"https://admin.example.com:8443/",
debugBuild = false
).getOrThrow()
)
assertFalse(
BackendEndpointPolicy.normalize(
"http://admin.example.com:8080",
debugBuild = false
).isSuccess
)
}
@Test
fun debugHttpIsLimitedToLoopback() {
assertTrue(
BackendEndpointPolicy.normalize(
"http://127.0.0.1:8080",
debugBuild = true
).isSuccess
)
assertFalse(
BackendEndpointPolicy.normalize(
"http://192.168.1.20:8080",
debugBuild = true
).isSuccess
)
}
@Test
fun rejectsEmbeddedCredentialsAndPaths() {
assertFalse(
BackendEndpointPolicy.normalize(
"https://user:pass@example.com/api",
debugBuild = false
).isSuccess
)
}
}
@@ -0,0 +1,59 @@
package com.roubao.autopilot.procurement
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ExecutionAuthorizationTest {
@Test
fun usesServerClockOffsetForOfflineDeadline() {
val expiry = "2026-07-27T01:30:00Z"
val localAtServerOneOClock = 1_000L
val offset = ExecutionAuthorization.serverClockOffset(
"2026-07-27T01:00:00Z",
localAtServerOneOClock
)
assertFalse(
ExecutionAuthorization.isExpired(
expiry,
offset,
localAtServerOneOClock + 29 * 60_000L
)
)
assertTrue(
ExecutionAuthorization.isExpired(
expiry,
offset,
localAtServerOneOClock + 30 * 60_000L
)
)
}
@Test
fun malformedExpiryFailsClosed() {
assertTrue(
ExecutionAuthorization.isExpired(
"not-a-time",
serverClockOffsetMillis = 0,
nowEpochMillis = 0
)
)
}
@Test
fun startRequiresAStablePreviewWindow() {
assertFalse(
StartAuthorization.isPreviewConfirmed(
previewReadyAtEpochMillis = 10_000L,
nowEpochMillis = 11_999L
)
)
assertTrue(
StartAuthorization.isPreviewConfirmed(
previewReadyAtEpochMillis = 10_000L,
nowEpochMillis = 12_000L
)
)
}
}
@@ -0,0 +1,221 @@
package com.roubao.autopilot.procurement
import java.security.MessageDigest
import kotlinx.coroutines.runBlocking
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okio.Buffer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class ProcurementApiClientTest {
private lateinit var server: MockWebServer
private lateinit var api: ProcurementApiClient
@Before
fun setUp() {
server = MockWebServer()
server.start()
api = ProcurementApiClient()
}
@After
fun tearDown() {
server.shutdown()
}
@Test
fun loginClaimStartAndReferenceImageFollowDeviceContract() = runBlocking {
server.enqueue(
jsonResponse(
"""
{
"access_token":"access-token-value",
"expires_in":3600,
"device":{"id":"device-id","enabled":true}
}
""".trimIndent()
)
)
val baseUrl = server.url("/").toString().trimEnd('/')
val session = api.login(
baseUrl,
LoginInput(
backendUrl = baseUrl,
username = "buyer01",
password = "private-password",
deviceId = "device-id",
deviceToken = "private-device-token"
),
appVersion = "1.4.2",
androidVersion = "16"
)
val loginRequest = server.takeRequest()
assertEquals("/api/v1/auth/token", loginRequest.path)
assertTrue(loginRequest.body.readUtf8().contains("\"username\":\"buyer01\""))
server.enqueue(
jsonResponse(
"""
{
"task":${taskJson("CLAIMED", 2)},
"replayed":false,
"server_time":"2026-07-27T01:00:00Z"
}
""".trimIndent()
)
)
val claim = api.claimNext(
session,
"device-id",
"claim-token-value",
"claim-idempotency-key"
)
val claimRequest = server.takeRequest()
assertEquals("/api/v1/tasks/claim-next", claimRequest.path)
assertEquals("claim-token-value", claimRequest.getHeader("X-Claim-Token"))
assertEquals(
"claim-idempotency-key",
claimRequest.getHeader("Idempotency-Key")
)
val task = assertNotNull(claim.task).let { claim.task!! }
assertEquals("测试商品", task.title)
assertEquals(2, task.quantity)
val jpeg = byteArrayOf(
0xFF.toByte(),
0xD8.toByte(),
0xFF.toByte(),
0xD9.toByte()
)
val hash = MessageDigest.getInstance("SHA-256")
.digest(jpeg)
.joinToString("") { "%02x".format(it) }
server.enqueue(
MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "image/jpeg")
.setHeader("ETag", "\"$hash\"")
.setBody(Buffer().write(jpeg))
)
val image = api.downloadReferenceImage(
session,
task,
"claim-token-value"
)
val imageRequest = server.takeRequest()
assertTrue(imageRequest.path!!.startsWith("/api/v1/tasks/task-id/reference-image"))
assertEquals(hash, image.sha256)
server.enqueue(
jsonResponse(
"""
{
"task":${taskJson("RUNNING", 3)},
"execution":{
"id":"execution-id",
"current_step":"PREFLIGHT",
"order_submitted":false,
"execution_expires_at":"2026-07-27T01:30:00Z"
},
"replayed":false,
"server_time":"2026-07-27T01:00:00Z"
}
""".trimIndent()
)
)
val started = api.start(
session,
task,
"claim-token-value",
"start-idempotency-key"
)
assertEquals("execution-id", started.executionId)
assertEquals("2026-07-27T01:30:00Z", started.executionExpiresAt)
}
@Test
fun noTaskIsAStableEmptyResult() = runBlocking {
server.enqueue(MockResponse().setResponseCode(204))
val result = api.claimNext(
session(),
"device-id",
"claim-token-value",
"claim-idempotency-key"
)
assertEquals(null, result.task)
}
@Test
fun referenceImageMustStayOnTheBackendOrigin() = runBlocking {
val failure = runCatching {
api.downloadReferenceImage(
session(),
task(
referenceImageUrl =
"https://credentials.example.invalid/reference.jpg"
),
"claim-token-value"
)
}.exceptionOrNull()
assertTrue(failure is ProcurementApiException)
assertEquals(
"REFERENCE_IMAGE_ORIGIN_INVALID",
(failure as ProcurementApiException).code
)
assertEquals(0, server.requestCount)
}
private fun session() = ProcurementSession(
backendUrl = server.url("/").toString().trimEnd('/'),
username = "buyer01",
deviceId = "device-id",
accessToken = "access-token-value",
expiresAtEpochMillis = Long.MAX_VALUE
)
private fun taskJson(status: String, version: Int): String =
"""
{
"id":"task-id",
"status":"$status",
"version":$version,
"claim_generation":1,
"claim_expires_at":"2026-07-27T01:30:00Z",
"title":"测试商品",
"description":"测试描述",
"sku":"SKU-01",
"reference_image_url":"/api/v1/tasks/task-id/reference-image?claim_generation=1",
"quantity":2,
"max_budget":"20.00",
"currency":"CNY"
}
""".trimIndent()
private fun task(referenceImageUrl: String) = RemotePurchaseTask(
id = "task-id",
status = "CLAIMED",
version = 2,
claimGeneration = 1,
claimExpiresAt = "2026-07-27T01:30:00Z",
title = "测试商品",
description = "测试描述",
sku = "SKU-01",
referenceImageUrl = referenceImageUrl,
quantity = 2,
maxBudget = "20.00",
currency = "CNY"
)
private fun jsonResponse(body: String) =
MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "application/json")
.setBody(body)
}