feat(t216): deliver recoverable device order commands
This commit is contained in:
@@ -713,9 +713,17 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("migration.New() after review error = %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err != nil {
|
||||
t.Fatalf("device command migration down: %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err == nil {
|
||||
t.Fatal("order workflow migration down succeeded with retained data")
|
||||
}
|
||||
if applied, err := runner.Up(context.Background()); err != nil {
|
||||
t.Fatalf("restore device command migration: %v", err)
|
||||
} else if applied != 1 {
|
||||
t.Fatalf("restored migrations = %d, want 1", applied)
|
||||
}
|
||||
|
||||
completePayload := fmt.Sprintf(
|
||||
`{"execution_id":%q,"claim_generation":%d,"task_content_sha256":%q,"execution_mode":"MANUAL_FIRST","outcome":"CANDIDATE_ACCEPTED","operator_reason":"人工核对标题、SKU和截图后接受","candidate":{"ordinal":1,"title":"手动候选","sku_text":"TEST-SKU","price":"12.00","product_url":"","image_url":"","card_signature":%q,"detail_signature":%q,"detail_evidence_sha256":%q,"specification_evidence_sha256":%q,"evidence_asset_ids":[%q,%q],"evaluation":null},"order_submitted":false}`,
|
||||
@@ -784,6 +792,326 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable(
|
||||
t *testing.T,
|
||||
) {
|
||||
fixture := newDeviceHTTPFixture(t)
|
||||
requireDeviceStatus(t, fixture.readyHeartbeat(t), http.StatusOK)
|
||||
taskID := fixture.createPendingTask(t)
|
||||
claim := fixture.claimNext(t, "order-command-claim", testOpaqueToken)
|
||||
requireDeviceStatus(t, claim, http.StatusOK)
|
||||
var claimed deviceLifecycleResponse
|
||||
decodeResponse(t, claim, &claimed)
|
||||
startPayload := fmt.Sprintf(
|
||||
`{"claim_generation":%d,"expected_version":%d}`,
|
||||
claimed.Task.ClaimGeneration,
|
||||
claimed.Task.Version,
|
||||
)
|
||||
start := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/start",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(startPayload),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
idempotencyKey: "order-command-start",
|
||||
})
|
||||
requireDeviceStatus(t, start, http.StatusOK)
|
||||
var started deviceLifecycleResponse
|
||||
decodeResponse(t, start, &started)
|
||||
candidateKey := seedDeviceOrderCommandCandidate(
|
||||
t,
|
||||
fixture,
|
||||
taskID,
|
||||
started.Execution.ID,
|
||||
)
|
||||
detail, err := fixture.tasks.Get(
|
||||
context.Background(),
|
||||
localAdminSubject,
|
||||
taskID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("get waiting task detail: %v", err)
|
||||
}
|
||||
authorizations, err := usecase.NewOrderAuthorizationService(
|
||||
fixture.store,
|
||||
usecase.SystemClock{},
|
||||
usecase.UUIDGenerator{},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewOrderAuthorizationService() error = %v", err)
|
||||
}
|
||||
created, err := authorizations.Create(
|
||||
context.Background(),
|
||||
usecase.CreateOrderAuthorizationCommand{
|
||||
ActorUserID: deviceTestAdminID,
|
||||
TaskID: taskID,
|
||||
IdempotencyKey: "order-command-authorization",
|
||||
ExecutionID: started.Execution.ID,
|
||||
TaskContentSHA256: usecase.TaskContentSHA256(detail.Task),
|
||||
ExpectedTaskVersion: detail.Task.Version,
|
||||
CandidateKey: candidateKey,
|
||||
ReasonSchemaVersion: 1,
|
||||
PrimaryReasonCode: "SELECTED_BEST_MATCH",
|
||||
Items: []usecase.OrderAuthorizationItemInput{{
|
||||
CandidateKey: candidateKey,
|
||||
Label: "ACCEPT",
|
||||
PrimaryReasonCode: "SKU_MATCH",
|
||||
ReasonCodes: []string{"SKU_MATCH", "IMAGE_MATCH"},
|
||||
}},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("create order authorization: %v", err)
|
||||
}
|
||||
pullPayload := fmt.Sprintf(
|
||||
`{"device_id":%q,"execution_id":%q,"claim_generation":%d}`,
|
||||
deviceTestDeviceID,
|
||||
started.Execution.ID,
|
||||
started.Task.ClaimGeneration,
|
||||
)
|
||||
pull := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/commands/next",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(pullPayload),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
})
|
||||
requireDeviceStatus(t, pull, http.StatusOK)
|
||||
var command struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
TaskID string `json:"task_id"`
|
||||
ExecutionID string `json:"execution_id"`
|
||||
Quantity int `json:"quantity"`
|
||||
CommandSHA256 string `json:"command_sha256"`
|
||||
AuthorizationStatus string `json:"authorization_status"`
|
||||
Candidate struct {
|
||||
Key string `json:"candidate_key"`
|
||||
ObservedOrdinal int `json:"observed_ordinal"`
|
||||
Title string `json:"title"`
|
||||
SKUText string `json:"sku_text"`
|
||||
CardSignature string `json:"card_signature"`
|
||||
DetailSignature string `json:"detail_signature"`
|
||||
DetailEvidence string `json:"detail_evidence_sha256"`
|
||||
SpecificationSHA string `json:"specification_evidence_sha256"`
|
||||
} `json:"candidate"`
|
||||
}
|
||||
decodeResponse(t, pull, &command)
|
||||
if command.ID != created.Authorization.ID ||
|
||||
command.Type != "CREATE_PENDING_ORDER" ||
|
||||
command.SchemaVersion != 1 ||
|
||||
command.TaskID != taskID ||
|
||||
command.ExecutionID != started.Execution.ID ||
|
||||
command.Quantity != 2 ||
|
||||
len(command.CommandSHA256) != 64 ||
|
||||
command.AuthorizationStatus != "DELIVERED" ||
|
||||
command.Candidate.Key != candidateKey ||
|
||||
command.Candidate.ObservedOrdinal != 1 ||
|
||||
command.Candidate.Title != "设备命令候选" ||
|
||||
command.Candidate.SKUText != "TEST-SKU-COMMAND" {
|
||||
t.Fatalf("order command = %+v", command)
|
||||
}
|
||||
replayedPull := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/commands/next",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(pullPayload),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
})
|
||||
requireDeviceStatus(t, replayedPull, http.StatusOK)
|
||||
if !strings.Contains(
|
||||
replayedPull.Body.String(),
|
||||
`"command_sha256":"`+command.CommandSHA256+`"`,
|
||||
) {
|
||||
t.Fatalf("replayed command = %s", replayedPull.Body.String())
|
||||
}
|
||||
badAckPayload := fmt.Sprintf(
|
||||
`{"device_id":%q,"execution_id":%q,"claim_generation":%d,"command_sha256":%q}`,
|
||||
deviceTestDeviceID,
|
||||
started.Execution.ID,
|
||||
started.Task.ClaimGeneration,
|
||||
strings.Repeat("0", 64),
|
||||
)
|
||||
badAck := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/commands/" + command.ID + "/ack",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(badAckPayload),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
idempotencyKey: "order-command-ack-bad",
|
||||
})
|
||||
requireDeviceStatus(t, badAck, http.StatusConflict)
|
||||
ackPayload := strings.Replace(
|
||||
badAckPayload,
|
||||
strings.Repeat("0", 64),
|
||||
command.CommandSHA256,
|
||||
1,
|
||||
)
|
||||
ack := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/commands/" + command.ID + "/ack",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(ackPayload),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
idempotencyKey: "order-command-ack",
|
||||
})
|
||||
requireDeviceStatus(t, ack, http.StatusOK)
|
||||
if !strings.Contains(ack.Body.String(), `"status":"ACKNOWLEDGED"`) {
|
||||
t.Fatalf("ack response = %s", ack.Body.String())
|
||||
}
|
||||
ackReplay := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/commands/" + command.ID + "/ack",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(ackPayload),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
idempotencyKey: "order-command-ack",
|
||||
})
|
||||
requireDeviceStatus(t, ackReplay, http.StatusOK)
|
||||
if !strings.Contains(ackReplay.Body.String(), `"replayed":true`) {
|
||||
t.Fatalf("ack replay = %s", ackReplay.Body.String())
|
||||
}
|
||||
acknowledgedPull := performDeviceRequest(
|
||||
t,
|
||||
fixture.router,
|
||||
deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/commands/next",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(pullPayload),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
},
|
||||
)
|
||||
requireDeviceStatus(t, acknowledgedPull, http.StatusOK)
|
||||
if !strings.Contains(
|
||||
acknowledgedPull.Body.String(),
|
||||
`"authorization_status":"ACKNOWLEDGED"`,
|
||||
) {
|
||||
t.Fatalf("acknowledged pull = %s", acknowledgedPull.Body.String())
|
||||
}
|
||||
var deliveredEvents, acknowledgedEvents int
|
||||
for eventType, target := range map[string]*int{
|
||||
"ORDER_AUTHORIZATION_DELIVERED": &deliveredEvents,
|
||||
"ORDER_AUTHORIZATION_ACKNOWLEDGED": &acknowledgedEvents,
|
||||
} {
|
||||
if err := fixture.db.QueryRow(
|
||||
`SELECT COUNT(*) FROM task_events
|
||||
WHERE task_id = ? AND event_type = ?`,
|
||||
taskID,
|
||||
eventType,
|
||||
).Scan(target); err != nil {
|
||||
t.Fatalf("count %s events: %v", eventType, err)
|
||||
}
|
||||
}
|
||||
if deliveredEvents != 1 || acknowledgedEvents != 1 {
|
||||
t.Fatalf(
|
||||
"delivery/ack events = %d/%d",
|
||||
deliveredEvents,
|
||||
acknowledgedEvents,
|
||||
)
|
||||
}
|
||||
runner, err := migration.New(fixture.db)
|
||||
if err != nil {
|
||||
t.Fatalf("migration.New() error = %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err == nil {
|
||||
t.Fatal("device command migration down succeeded with command data")
|
||||
}
|
||||
}
|
||||
|
||||
func seedDeviceOrderCommandCandidate(
|
||||
t *testing.T,
|
||||
fixture *deviceHTTPFixture,
|
||||
taskID string,
|
||||
executionID string,
|
||||
) string {
|
||||
t.Helper()
|
||||
detail, err := fixture.tasks.Get(
|
||||
context.Background(),
|
||||
localAdminSubject,
|
||||
taskID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("get running task detail: %v", err)
|
||||
}
|
||||
taskHash := usecase.TaskContentSHA256(detail.Task)
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
candidateKey := strings.Repeat("7", 64)
|
||||
if _, err := fixture.db.Exec(
|
||||
`INSERT INTO candidate_search_runs (
|
||||
execution_id, task_id, task_content_sha256, execution_mode,
|
||||
search_query, started_at, received_at, observation_count,
|
||||
collection_complete, received_after_execution_expiry
|
||||
) VALUES (?, ?, ?, 'MANUAL_FIRST', 'PDD_IMAGE_SEARCH', ?, ?, 1, 1, 0)`,
|
||||
executionID,
|
||||
taskID,
|
||||
taskHash,
|
||||
now,
|
||||
now,
|
||||
); err != nil {
|
||||
t.Fatalf("seed order command search run: %v", err)
|
||||
}
|
||||
if _, err := fixture.db.Exec(
|
||||
`INSERT INTO candidate_observations (
|
||||
execution_id, task_id, ordinal, title, sku_text, price_text,
|
||||
product_url, image_url, evidence_asset_ids_json,
|
||||
collection_status, observed_at
|
||||
) VALUES (?, ?, 1, '设备命令候选', 'TEST-SKU-COMMAND', '21.50',
|
||||
'', '', '[]', 'COMPLETE', ?)`,
|
||||
executionID,
|
||||
taskID,
|
||||
now,
|
||||
); err != nil {
|
||||
t.Fatalf("seed order command observation: %v", err)
|
||||
}
|
||||
if _, err := fixture.db.Exec(
|
||||
`INSERT INTO candidate_observation_identities (
|
||||
candidate_key, execution_id, candidate_ordinal,
|
||||
card_signature, detail_signature, detail_evidence_sha256,
|
||||
specification_evidence_sha256, identity_version, created_at
|
||||
) VALUES (?, ?, 1, ?, ?, ?, ?, 1, ?)`,
|
||||
candidateKey,
|
||||
executionID,
|
||||
strings.Repeat("8", 64),
|
||||
strings.Repeat("9", 64),
|
||||
strings.Repeat("a", 64),
|
||||
strings.Repeat("b", 64),
|
||||
now,
|
||||
); err != nil {
|
||||
t.Fatalf("seed order command identity: %v", err)
|
||||
}
|
||||
if _, err := fixture.db.Exec(
|
||||
`UPDATE purchase_tasks
|
||||
SET status = 'WAITING_CONFIRMATION',
|
||||
version = version + 1,
|
||||
updated_at = ?
|
||||
WHERE id = ? AND status = 'RUNNING'`,
|
||||
now,
|
||||
taskID,
|
||||
); err != nil {
|
||||
t.Fatalf("move order command task to waiting: %v", err)
|
||||
}
|
||||
if _, err := fixture.db.Exec(
|
||||
`UPDATE task_executions
|
||||
SET current_step = 'WAITING_ADMIN_CONFIRMATION',
|
||||
last_heartbeat_at = ?
|
||||
WHERE id = ?`,
|
||||
now,
|
||||
executionID,
|
||||
); err != nil {
|
||||
t.Fatalf("move order command execution to waiting: %v", err)
|
||||
}
|
||||
return candidateKey
|
||||
}
|
||||
|
||||
func TestDeviceReleaseReturnsClaimedTaskToPending(t *testing.T) {
|
||||
fixture := newDeviceHTTPFixture(t)
|
||||
requireDeviceStatus(t, fixture.readyHeartbeat(t), http.StatusOK)
|
||||
@@ -1020,11 +1348,16 @@ func newDeviceHTTPFixture(t *testing.T) *deviceHTTPFixture {
|
||||
if err != nil {
|
||||
t.Fatalf("usecase.NewExecutionResultService() error = %v", err)
|
||||
}
|
||||
commands, err := usecase.NewDeviceOrderCommandService(store, clock, ids)
|
||||
if err != nil {
|
||||
t.Fatalf("usecase.NewDeviceOrderCommandService() error = %v", err)
|
||||
}
|
||||
deviceRoutes, err := NewDeviceRouteRegistrar(
|
||||
DeviceServices{
|
||||
Lifecycle: lifecycle,
|
||||
Assets: assets,
|
||||
Results: results,
|
||||
Commands: commands,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user