feat(t206): connect Android procurement tasks
This commit is contained in:
+51
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+59
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
+221
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user