feat(t207): audit local procurement results
This commit is contained in:
@@ -15,6 +15,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -478,6 +479,141 @@ func TestDeviceStartAndTaskHeartbeatUseClaimContract(t *testing.T) {
|
||||
assertNoClaimSecret(t, taskHeartbeat, testOpaqueToken)
|
||||
}
|
||||
|
||||
func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
|
||||
fixture := newDeviceHTTPFixture(t)
|
||||
requireDeviceStatus(t, fixture.readyHeartbeat(t), http.StatusOK)
|
||||
taskID := fixture.createPendingTask(t)
|
||||
claimedResponse := fixture.claimNext(t, "claim-for-results", testOpaqueToken)
|
||||
requireDeviceStatus(t, claimedResponse, http.StatusOK)
|
||||
var claimed deviceLifecycleResponse
|
||||
decodeResponse(t, claimedResponse, &claimed)
|
||||
|
||||
startResponse := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/start",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(fmt.Sprintf(
|
||||
`{"claim_generation":%d,"expected_version":%d}`,
|
||||
claimed.Task.ClaimGeneration,
|
||||
claimed.Task.Version,
|
||||
)),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
idempotencyKey: "start-for-results",
|
||||
})
|
||||
requireDeviceStatus(t, startResponse, http.StatusOK)
|
||||
var started deviceLifecycleResponse
|
||||
decodeResponse(t, startResponse, &started)
|
||||
|
||||
occurredAt := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
events := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/events",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(fmt.Sprintf(
|
||||
`{"execution_id":%q,"claim_generation":%d,"events":[{"event_id":"00000000-0000-4000-8000-000000000701","step":"SEARCH","type":"SEARCH_STARTED","message":"开始采集候选","occurred_at":%q}]}`,
|
||||
started.Execution.ID,
|
||||
started.Task.ClaimGeneration,
|
||||
occurredAt,
|
||||
)),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
idempotencyKey: "result-events-1",
|
||||
})
|
||||
requireDeviceStatus(t, events, http.StatusOK)
|
||||
|
||||
evidence := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/evidence",
|
||||
contentType: "image/jpeg",
|
||||
body: deviceReferenceImage(t, 701),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
idempotencyKey: "result-evidence-1",
|
||||
executionID: started.Execution.ID,
|
||||
claimGeneration: started.Task.ClaimGeneration,
|
||||
})
|
||||
requireDeviceStatus(t, evidence, http.StatusCreated)
|
||||
var evidenceResponse struct {
|
||||
Evidence struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"evidence"`
|
||||
}
|
||||
decodeResponse(t, evidence, &evidenceResponse)
|
||||
if evidenceResponse.Evidence.ID == "" {
|
||||
t.Fatalf("evidence response = %s", evidence.Body.String())
|
||||
}
|
||||
|
||||
detail, err := fixture.tasks.Get(context.Background(), "local-admin", taskID)
|
||||
if err != nil {
|
||||
t.Fatalf("get task for content hash: %v", err)
|
||||
}
|
||||
taskHash := usecase.TaskContentSHA256(detail.Task)
|
||||
candidatePayload := fmt.Sprintf(
|
||||
`{"execution_id":%q,"claim_generation":%d,"task_content_sha256":%q,"execution_mode":"MANUAL_FIRST","search_query":"TEST-SKU","candidates":[{"ordinal":1,"title":"手动候选","sku_text":"TEST-SKU","price":"12.00","product_url":"https://example.test/product/1","image_url":"https://example.test/image/1.jpg","evidence_asset_ids":[%q],"evaluation":null}]}`,
|
||||
started.Execution.ID,
|
||||
started.Task.ClaimGeneration,
|
||||
taskHash,
|
||||
evidenceResponse.Evidence.ID,
|
||||
)
|
||||
candidates := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/candidates",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(candidatePayload),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
idempotencyKey: "result-candidates-1",
|
||||
})
|
||||
requireDeviceStatus(t, candidates, http.StatusOK)
|
||||
|
||||
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":"https://example.test/product/1","image_url":"https://example.test/image/1.jpg","evidence_asset_ids":[%q],"evaluation":null},"order_submitted":false}`,
|
||||
started.Execution.ID,
|
||||
started.Task.ClaimGeneration,
|
||||
taskHash,
|
||||
evidenceResponse.Evidence.ID,
|
||||
)
|
||||
complete := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/complete",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(completePayload),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
idempotencyKey: "result-complete-1",
|
||||
})
|
||||
requireDeviceStatus(t, complete, http.StatusOK)
|
||||
assertNoClaimSecret(t, complete, testOpaqueToken)
|
||||
replayed := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/complete",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(completePayload),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
idempotencyKey: "result-complete-1",
|
||||
})
|
||||
requireDeviceStatus(t, replayed, http.StatusOK)
|
||||
if !strings.Contains(replayed.Body.String(), `"replayed":true`) {
|
||||
t.Fatalf("terminal replay response = %s", replayed.Body.String())
|
||||
}
|
||||
|
||||
detail, err = fixture.tasks.Get(context.Background(), "local-admin", taskID)
|
||||
if err != nil {
|
||||
t.Fatalf("get task result detail: %v", err)
|
||||
}
|
||||
if detail.Task.Status != domain.TaskStatusSucceeded ||
|
||||
detail.Report == nil ||
|
||||
detail.Report.Outcome == nil ||
|
||||
detail.Report.Outcome.OrderSubmitted ||
|
||||
len(detail.Report.Events) != 1 ||
|
||||
len(detail.Report.EvidenceAssets) != 1 ||
|
||||
detail.Report.CandidateBatch == nil {
|
||||
t.Fatalf("execution report = %+v", detail.Report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceReleaseReturnsClaimedTaskToPending(t *testing.T) {
|
||||
fixture := newDeviceHTTPFixture(t)
|
||||
requireDeviceStatus(t, fixture.readyHeartbeat(t), http.StatusOK)
|
||||
@@ -710,10 +846,15 @@ func newDeviceHTTPFixture(t *testing.T) *deviceHTTPFixture {
|
||||
if err != nil {
|
||||
t.Fatalf("usecase.NewLifecycleService() error = %v", err)
|
||||
}
|
||||
results, err := usecase.NewExecutionResultService(store, files, clock, ids)
|
||||
if err != nil {
|
||||
t.Fatalf("usecase.NewExecutionResultService() error = %v", err)
|
||||
}
|
||||
deviceRoutes, err := NewDeviceRouteRegistrar(
|
||||
DeviceServices{
|
||||
Lifecycle: lifecycle,
|
||||
Assets: assets,
|
||||
Results: results,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
@@ -880,13 +1021,15 @@ func deviceReferenceImage(t *testing.T, index int) io.Reader {
|
||||
}
|
||||
|
||||
type deviceRequest struct {
|
||||
method string
|
||||
target string
|
||||
contentType string
|
||||
body io.Reader
|
||||
bearerToken string
|
||||
claimToken string
|
||||
idempotencyKey string
|
||||
method string
|
||||
target string
|
||||
contentType string
|
||||
body io.Reader
|
||||
bearerToken string
|
||||
claimToken string
|
||||
idempotencyKey string
|
||||
executionID string
|
||||
claimGeneration int64
|
||||
}
|
||||
|
||||
func performDeviceRequest(
|
||||
@@ -911,6 +1054,13 @@ func performDeviceRequest(
|
||||
if spec.idempotencyKey != "" {
|
||||
request.Header.Set("Idempotency-Key", spec.idempotencyKey)
|
||||
}
|
||||
if spec.executionID != "" {
|
||||
request.Header.Set("X-Execution-ID", spec.executionID)
|
||||
request.Header.Set(
|
||||
"X-Claim-Generation",
|
||||
strconv.FormatInt(spec.claimGeneration, 10),
|
||||
)
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
return response
|
||||
|
||||
Reference in New Issue
Block a user