feat(t216): deliver recoverable device order commands
This commit is contained in:
+37
@@ -0,0 +1,37 @@
|
||||
package com.roubao.autopilot.procurement
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class OrderCommandIntegrityTest {
|
||||
@Test
|
||||
fun hashMatchesGoContractVector() {
|
||||
val command = PendingOrderCommand(
|
||||
id = "00000000-0000-4000-8000-000000000009",
|
||||
schemaVersion = 1,
|
||||
type = "CREATE_PENDING_ORDER",
|
||||
authorizationVersion = 3,
|
||||
taskId = "00000000-0000-4000-8000-000000000001",
|
||||
executionId = "00000000-0000-4000-8000-000000000002",
|
||||
taskContentSha256 = "a".repeat(64),
|
||||
originalSku = "红色 M",
|
||||
quantity = 2,
|
||||
candidate = OrderCommandCandidate(
|
||||
candidateKey = "candidate-key-1",
|
||||
observedOrdinal = 1,
|
||||
title = "红色连衣裙",
|
||||
skuText = "红色 / M",
|
||||
priceText = "19.90 CNY",
|
||||
cardSignature = "b".repeat(64),
|
||||
detailSignature = "c".repeat(64),
|
||||
detailEvidenceSha256 = "d".repeat(64),
|
||||
specificationEvidenceSha256 = "e".repeat(64)
|
||||
),
|
||||
commandSha256 =
|
||||
"cf3cd802366d4de49d51940551385f7e5bd3283ef18cbf245a227c679d1058f3",
|
||||
authorizationStatus = "DELIVERED"
|
||||
)
|
||||
|
||||
assertEquals(command.commandSha256, OrderCommandIntegrity.sha256(command))
|
||||
}
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
package com.roubao.autopilot.procurement
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class OrderCommandSynchronizationTest {
|
||||
@Test
|
||||
fun commandIsPersistedBeforeAcknowledgementAndReplayUsesSameKey() = runBlocking {
|
||||
val task = task()
|
||||
val execution = execution()
|
||||
val delivered = command(task, execution)
|
||||
val calls = mutableListOf<String>()
|
||||
var persisted: PendingOrderCommand? = null
|
||||
|
||||
val result = OrderCommandSynchronization.synchronize(
|
||||
existing = null,
|
||||
task = task,
|
||||
execution = execution,
|
||||
pull = {
|
||||
calls += "pull"
|
||||
delivered
|
||||
},
|
||||
persist = {
|
||||
calls += if (it.acknowledged) "persist-ack" else "persist-command"
|
||||
persisted = it
|
||||
},
|
||||
acknowledge = { command, key ->
|
||||
calls += "ack:$key"
|
||||
assertEquals(command, persisted)
|
||||
OrderCommandAcknowledgement(
|
||||
commandId = command.id,
|
||||
status = "ACKNOWLEDGED",
|
||||
replayed = false
|
||||
)
|
||||
},
|
||||
newIdempotencyKey = { "stable-ack-key" }
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
"pull",
|
||||
"persist-command",
|
||||
"ack:stable-ack-key",
|
||||
"persist-ack"
|
||||
),
|
||||
calls
|
||||
)
|
||||
assertTrue(requireNotNull(result).acknowledged)
|
||||
assertEquals("stable-ack-key", result.acknowledgementIdempotencyKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun persistenceFailurePreventsAcknowledgement() = runBlocking {
|
||||
val task = task()
|
||||
val execution = execution()
|
||||
var acknowledged = false
|
||||
|
||||
val failure = runCatching {
|
||||
OrderCommandSynchronization.synchronize(
|
||||
existing = null,
|
||||
task = task,
|
||||
execution = execution,
|
||||
pull = { command(task, execution) },
|
||||
persist = { error("storage unavailable") },
|
||||
acknowledge = { _, _ ->
|
||||
acknowledged = true
|
||||
error("must not acknowledge")
|
||||
},
|
||||
newIdempotencyKey = { "stable-ack-key" }
|
||||
)
|
||||
}.exceptionOrNull()
|
||||
|
||||
assertEquals("storage unavailable", failure?.message)
|
||||
assertEquals(false, acknowledged)
|
||||
}
|
||||
|
||||
private fun task() = RemotePurchaseTask(
|
||||
id = "00000000-0000-4000-8000-000000000001",
|
||||
status = "WAITING_CONFIRMATION",
|
||||
version = 4,
|
||||
claimGeneration = 1,
|
||||
claimExpiresAt = "2099-01-01T00:00:00Z",
|
||||
title = "测试商品",
|
||||
description = "",
|
||||
sku = "红色 M",
|
||||
imageAssetId = "00000000-0000-4000-8000-000000000003",
|
||||
referenceImageUrl = "/api/v1/tasks/task/reference-image",
|
||||
quantity = 2,
|
||||
maxBudget = "20.00",
|
||||
currency = "CNY"
|
||||
)
|
||||
|
||||
private fun execution() = RunningExecution(
|
||||
id = "00000000-0000-4000-8000-000000000002",
|
||||
currentStep = "WAITING_ADMIN_CONFIRMATION",
|
||||
expiresAt = "2099-01-01T00:00:00Z",
|
||||
serverClockOffsetMillis = 0
|
||||
)
|
||||
|
||||
private fun command(
|
||||
task: RemotePurchaseTask,
|
||||
execution: RunningExecution
|
||||
): PendingOrderCommand {
|
||||
val unsigned = PendingOrderCommand(
|
||||
id = "00000000-0000-4000-8000-000000000009",
|
||||
schemaVersion = 1,
|
||||
type = "CREATE_PENDING_ORDER",
|
||||
authorizationVersion = 1,
|
||||
taskId = task.id,
|
||||
executionId = execution.id,
|
||||
taskContentSha256 = ExecutionTaskHash.sha256(task),
|
||||
originalSku = task.sku,
|
||||
quantity = task.quantity,
|
||||
candidate = OrderCommandCandidate(
|
||||
candidateKey = "f".repeat(64),
|
||||
observedOrdinal = 1,
|
||||
title = "测试候选",
|
||||
skuText = "红色 / M",
|
||||
priceText = "19.90",
|
||||
cardSignature = "b".repeat(64),
|
||||
detailSignature = "c".repeat(64),
|
||||
detailEvidenceSha256 = "d".repeat(64),
|
||||
specificationEvidenceSha256 = "e".repeat(64)
|
||||
),
|
||||
commandSha256 = "0".repeat(64),
|
||||
authorizationStatus = "DELIVERED"
|
||||
)
|
||||
return unsigned.copy(
|
||||
commandSha256 = OrderCommandIntegrity.sha256(unsigned)
|
||||
)
|
||||
}
|
||||
}
|
||||
+103
@@ -250,6 +250,109 @@ class ProcurementApiClientTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun orderCommandPullAndAcknowledgementFollowDeviceContract() = runBlocking {
|
||||
val execution = RunningExecution(
|
||||
id = "execution-id",
|
||||
currentStep = "WAITING_ADMIN_CONFIRMATION",
|
||||
expiresAt = "2099-01-01T00:00:00Z",
|
||||
serverClockOffsetMillis = 0
|
||||
)
|
||||
server.enqueue(
|
||||
jsonResponse(
|
||||
"""
|
||||
{
|
||||
"id":"command-id",
|
||||
"schema_version":1,
|
||||
"type":"CREATE_PENDING_ORDER",
|
||||
"authorization_version":1,
|
||||
"task_id":"task-id",
|
||||
"execution_id":"execution-id",
|
||||
"task_content_sha256":"${"a".repeat(64)}",
|
||||
"original_sku":"SKU-01",
|
||||
"quantity":2,
|
||||
"candidate":{
|
||||
"candidate_key":"candidate-key-1",
|
||||
"observed_ordinal":1,
|
||||
"title":"候选商品",
|
||||
"sku_text":"红色 / M",
|
||||
"price_text":"20.00",
|
||||
"card_signature":"${"b".repeat(64)}",
|
||||
"detail_signature":"${"c".repeat(64)}",
|
||||
"detail_evidence_sha256":"${"d".repeat(64)}",
|
||||
"specification_evidence_sha256":"${"e".repeat(64)}"
|
||||
},
|
||||
"command_sha256":"${"f".repeat(64)}",
|
||||
"authorization_status":"DELIVERED"
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
)
|
||||
val command = requireNotNull(
|
||||
api.pullOrderCommand(
|
||||
session(),
|
||||
task("/api/v1/tasks/task-id/reference-image?claim_generation=1"),
|
||||
execution,
|
||||
"claim-token-value"
|
||||
)
|
||||
)
|
||||
val pullRequest = server.takeRequest()
|
||||
assertEquals(
|
||||
"/api/v1/tasks/task-id/commands/next",
|
||||
pullRequest.path
|
||||
)
|
||||
assertEquals(
|
||||
"claim-token-value",
|
||||
pullRequest.getHeader("X-Claim-Token")
|
||||
)
|
||||
assertTrue(pullRequest.body.readUtf8().contains("\"execution_id\":\"execution-id\""))
|
||||
assertEquals("candidate-key-1", command.candidate.candidateKey)
|
||||
|
||||
server.enqueue(
|
||||
jsonResponse(
|
||||
"""{"command_id":"command-id","status":"ACKNOWLEDGED","replayed":false}"""
|
||||
)
|
||||
)
|
||||
val acknowledgement = api.acknowledgeOrderCommand(
|
||||
session(),
|
||||
task("/api/v1/tasks/task-id/reference-image?claim_generation=1"),
|
||||
execution,
|
||||
"claim-token-value",
|
||||
command,
|
||||
"command-ack-idempotency-key"
|
||||
)
|
||||
val ackRequest = server.takeRequest()
|
||||
assertEquals(
|
||||
"/api/v1/tasks/task-id/commands/command-id/ack",
|
||||
ackRequest.path
|
||||
)
|
||||
assertEquals(
|
||||
"command-ack-idempotency-key",
|
||||
ackRequest.getHeader("Idempotency-Key")
|
||||
)
|
||||
assertTrue(ackRequest.body.readUtf8().contains(command.commandSha256))
|
||||
assertEquals("ACKNOWLEDGED", acknowledgement.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noOrderCommandIsAStableEmptyResult() = runBlocking {
|
||||
server.enqueue(MockResponse().setResponseCode(204))
|
||||
|
||||
val command = api.pullOrderCommand(
|
||||
session(),
|
||||
task("/api/v1/tasks/task-id/reference-image?claim_generation=1"),
|
||||
RunningExecution(
|
||||
id = "execution-id",
|
||||
currentStep = "WAITING_ADMIN_CONFIRMATION",
|
||||
expiresAt = "2099-01-01T00:00:00Z",
|
||||
serverClockOffsetMillis = 0
|
||||
),
|
||||
"claim-token-value"
|
||||
)
|
||||
|
||||
assertEquals(null, command)
|
||||
}
|
||||
|
||||
private fun session() = ProcurementSession(
|
||||
backendUrl = server.url("/").toString().trimEnd('/'),
|
||||
username = "buyer01",
|
||||
|
||||
Reference in New Issue
Block a user