feat(t207): audit local procurement results
This commit is contained in:
@@ -24,12 +24,13 @@ const (
|
||||
)
|
||||
|
||||
type AdminServices struct {
|
||||
Assets *usecase.AssetService
|
||||
Tasks *usecase.TaskService
|
||||
Assets *usecase.AssetService
|
||||
Tasks *usecase.TaskService
|
||||
Results *usecase.ExecutionResultService
|
||||
}
|
||||
|
||||
func (s AdminServices) validate() error {
|
||||
if s.Assets == nil || s.Tasks == nil {
|
||||
if s.Assets == nil || s.Tasks == nil || s.Results == nil {
|
||||
return errors.New("admin services are required")
|
||||
}
|
||||
return nil
|
||||
@@ -49,10 +50,38 @@ func registerAdminAPI(routes gin.IRoutes, services AdminServices) error {
|
||||
routes.POST("/api/v1/tasks", handler.createTask)
|
||||
routes.GET("/api/v1/tasks", handler.listTasks)
|
||||
routes.GET("/api/v1/tasks/:id", handler.taskDetail)
|
||||
routes.GET(
|
||||
"/api/v1/tasks/:id/evidence/:evidence_id/content",
|
||||
handler.evidenceContent,
|
||||
)
|
||||
routes.POST("/api/v1/tasks/:id/cancel", handler.cancelTask)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *adminHandlers) evidenceContent(ctx *gin.Context) {
|
||||
result, err := h.services.Results.OpenEvidence(
|
||||
ctx.Request.Context(),
|
||||
ctx.Param("id"),
|
||||
ctx.Param("evidence_id"),
|
||||
)
|
||||
if err != nil {
|
||||
writeUsecaseError(ctx, err)
|
||||
return
|
||||
}
|
||||
defer result.Content.Close()
|
||||
ctx.Header("Cache-Control", "private, no-store")
|
||||
ctx.Header("Content-Type", result.Evidence.MediaType)
|
||||
ctx.Header("Content-Length", strconv.FormatInt(result.Evidence.SizeBytes, 10))
|
||||
ctx.Header("ETag", `"`+result.Evidence.SHA256+`"`)
|
||||
ctx.Header("X-Content-Type-Options", "nosniff")
|
||||
ctx.Header(
|
||||
"Content-Disposition",
|
||||
`inline; filename="`+result.Evidence.ID+`.jpg"`,
|
||||
)
|
||||
ctx.Status(http.StatusOK)
|
||||
_, _ = io.Copy(ctx.Writer, result.Content)
|
||||
}
|
||||
|
||||
func (h *adminHandlers) uploadAsset(ctx *gin.Context) {
|
||||
if !hasMediaType(ctx, "multipart/form-data") {
|
||||
writePublicError(
|
||||
@@ -317,6 +346,10 @@ func (h *adminHandlers) taskDetail(ctx *gin.Context) {
|
||||
if detail.Execution != nil {
|
||||
execution = executionResponse(*detail.Execution)
|
||||
}
|
||||
var executionReport any
|
||||
if detail.Report != nil {
|
||||
executionReport = executionReportResponse(detail.Report)
|
||||
}
|
||||
ctx.Header("Cache-Control", "no-store")
|
||||
ctx.JSON(http.StatusOK, gin.H{
|
||||
"id": detail.Task.ID,
|
||||
@@ -337,6 +370,7 @@ func (h *adminHandlers) taskDetail(ctx *gin.Context) {
|
||||
"derived_requirement": nil,
|
||||
"claim": claim,
|
||||
"execution": execution,
|
||||
"execution_report": executionReport,
|
||||
"events": events,
|
||||
"assets": []gin.H{
|
||||
assetResponse(detail.Asset),
|
||||
@@ -344,6 +378,78 @@ func (h *adminHandlers) taskDetail(ctx *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func executionReportResponse(report *domain.ExecutionReport) gin.H {
|
||||
events := make([]gin.H, 0, len(report.Events))
|
||||
for _, event := range report.Events {
|
||||
events = append(events, gin.H{
|
||||
"id": event.ID,
|
||||
"step": event.Step,
|
||||
"type": event.Type,
|
||||
"message": event.Message,
|
||||
"occurred_at": formatTime(event.OccurredAt),
|
||||
"received_at": formatTime(event.ReceivedAt),
|
||||
"received_after_execution_expiry": event.ReceivedAfterExecutionExpiry,
|
||||
})
|
||||
}
|
||||
evidence := make([]gin.H, 0, len(report.EvidenceAssets))
|
||||
for _, asset := range report.EvidenceAssets {
|
||||
evidence = append(evidence, gin.H{
|
||||
"id": asset.ID,
|
||||
"media_type": asset.MediaType,
|
||||
"size_bytes": asset.SizeBytes,
|
||||
"sha256": asset.SHA256,
|
||||
"created_at": formatTime(asset.CreatedAt),
|
||||
"received_after_execution_expiry": asset.ReceivedAfterExecutionExpiry,
|
||||
})
|
||||
}
|
||||
response := gin.H{
|
||||
"events": events,
|
||||
"evidence": evidence,
|
||||
}
|
||||
if batch := report.CandidateBatch; batch != nil {
|
||||
response["candidate_batch"] = gin.H{
|
||||
"task_content_sha256": batch.TaskContentSHA256,
|
||||
"execution_mode": batch.ExecutionMode,
|
||||
"search_query": batch.SearchQuery,
|
||||
"provenance": decodedAuditJSON(batch.ProvenanceJSON),
|
||||
"candidates": decodedAuditJSON(&batch.CandidatesJSON),
|
||||
"recommendation": decodedAuditJSON(batch.RecommendationJSON),
|
||||
"received_at": formatTime(batch.ReceivedAt),
|
||||
"received_after_execution_expiry": batch.ReceivedAfterExecutionExpiry,
|
||||
}
|
||||
}
|
||||
if outcome := report.Outcome; outcome != nil {
|
||||
response["outcome"] = gin.H{
|
||||
"result_type": outcome.ResultType,
|
||||
"execution_mode": outcome.ExecutionMode,
|
||||
"task_content_sha256": outcome.TaskContentSHA256,
|
||||
"outcome": outcome.Outcome,
|
||||
"operator_reason": outcome.OperatorReason,
|
||||
"selected_candidate": decodedAuditJSON(outcome.SelectedCandidateJSON),
|
||||
"evidence_asset_ids": decodedAuditJSON(outcome.EvidenceAssetIDsJSON),
|
||||
"error_code": outcome.ErrorCode,
|
||||
"error_message": outcome.ErrorMessage,
|
||||
"error_step": outcome.ErrorStep,
|
||||
"retryable": outcome.Retryable,
|
||||
"order_submitted": outcome.OrderSubmitted,
|
||||
"received_at": formatTime(outcome.ReceivedAt),
|
||||
"received_after_execution_expiry": outcome.ReceivedAfterExecutionExpiry,
|
||||
}
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
func decodedAuditJSON(value *string) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
var decoded any
|
||||
if err := json.Unmarshal([]byte(*value), &decoded); err != nil {
|
||||
return nil
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
func (h *adminHandlers) cancelTask(ctx *gin.Context) {
|
||||
if !hasMediaType(ctx, "application/json") {
|
||||
writePublicError(
|
||||
|
||||
@@ -349,8 +349,12 @@ func newAdminIntegrationRouter(t *testing.T) http.Handler {
|
||||
if err != nil {
|
||||
t.Fatalf("usecase.NewTaskService() error = %v", err)
|
||||
}
|
||||
results, err := usecase.NewExecutionResultService(repositories, files, clock, ids)
|
||||
if err != nil {
|
||||
t.Fatalf("usecase.NewExecutionResultService() error = %v", err)
|
||||
}
|
||||
registrar, err := NewAdminRouteRegistrar(
|
||||
AdminServices{Assets: assets, Tasks: tasks},
|
||||
AdminServices{Assets: assets, Tasks: tasks, Results: results},
|
||||
emptyAdminWeb{},
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -19,10 +19,11 @@ const claimTokenHeader = "X-Claim-Token"
|
||||
type DeviceServices struct {
|
||||
Lifecycle *usecase.LifecycleService
|
||||
Assets *usecase.AssetService
|
||||
Results *usecase.ExecutionResultService
|
||||
}
|
||||
|
||||
func (services DeviceServices) validate() error {
|
||||
if services.Lifecycle == nil || services.Assets == nil {
|
||||
if services.Lifecycle == nil || services.Assets == nil || services.Results == nil {
|
||||
return errors.New("device services are required")
|
||||
}
|
||||
return nil
|
||||
@@ -68,6 +69,11 @@ func NewDeviceRouteRegistrar(
|
||||
"/api/v1/tasks/:id/cancel-ack",
|
||||
handler.acknowledgeCancellation,
|
||||
)
|
||||
routes.POST("/api/v1/tasks/:id/events", handler.appendEvents)
|
||||
routes.POST("/api/v1/tasks/:id/evidence", handler.uploadEvidence)
|
||||
routes.POST("/api/v1/tasks/:id/candidates", handler.storeCandidates)
|
||||
routes.POST("/api/v1/tasks/:id/complete", handler.completeTask)
|
||||
routes.POST("/api/v1/tasks/:id/fail", handler.failTask)
|
||||
return nil
|
||||
}, nil
|
||||
}
|
||||
@@ -367,6 +373,257 @@ func (handler *deviceHandlers) acknowledgeCancellation(
|
||||
})
|
||||
}
|
||||
|
||||
func (handler *deviceHandlers) appendEvents(ctx *gin.Context) {
|
||||
principal, ok := devicePrincipal(ctx)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
ExecutionID string `json:"execution_id"`
|
||||
ClaimGeneration int64 `json:"claim_generation"`
|
||||
Events []usecase.ClientExecutionEvent `json:"events"`
|
||||
}
|
||||
if !decodeDeviceJSON(ctx, &request) {
|
||||
return
|
||||
}
|
||||
replayed, err := handler.services.Results.AppendEvents(
|
||||
ctx.Request.Context(),
|
||||
usecase.AppendExecutionEventsCommand{
|
||||
Identity: handler.executionResultIdentity(
|
||||
ctx, principal, request.ExecutionID, request.ClaimGeneration,
|
||||
),
|
||||
Events: request.Events,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
writeUsecaseError(ctx, err)
|
||||
return
|
||||
}
|
||||
ctx.Header("Cache-Control", "no-store")
|
||||
ctx.JSON(http.StatusOK, gin.H{"replayed": replayed})
|
||||
}
|
||||
|
||||
func (handler *deviceHandlers) uploadEvidence(ctx *gin.Context) {
|
||||
principal, ok := devicePrincipal(ctx)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !isEvidenceMediaType(ctx.GetHeader("Content-Type")) {
|
||||
writePublicError(
|
||||
ctx,
|
||||
http.StatusUnsupportedMediaType,
|
||||
"ASSET_MEDIA_TYPE_UNSUPPORTED",
|
||||
"JPEG, PNG, or WebP evidence is required",
|
||||
false,
|
||||
gin.H{},
|
||||
)
|
||||
return
|
||||
}
|
||||
generation, err := strconv.ParseInt(
|
||||
strings.TrimSpace(ctx.GetHeader("X-Claim-Generation")),
|
||||
10,
|
||||
64,
|
||||
)
|
||||
if err != nil {
|
||||
writePublicError(
|
||||
ctx,
|
||||
http.StatusUnprocessableEntity,
|
||||
"EXECUTION_RESULT_INVALID",
|
||||
"execution result request is invalid",
|
||||
false,
|
||||
fieldDetails("claim_generation", "must be a positive integer"),
|
||||
)
|
||||
return
|
||||
}
|
||||
ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, maxMultipartBytes)
|
||||
result, err := handler.services.Results.UploadEvidence(
|
||||
ctx.Request.Context(),
|
||||
usecase.UploadExecutionEvidenceCommand{
|
||||
Identity: handler.executionResultIdentity(
|
||||
ctx,
|
||||
principal,
|
||||
ctx.GetHeader("X-Execution-ID"),
|
||||
generation,
|
||||
),
|
||||
DeclaredMediaType: ctx.GetHeader("Content-Type"),
|
||||
Content: ctx.Request.Body,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
writeUsecaseError(ctx, err)
|
||||
return
|
||||
}
|
||||
ctx.Header("Cache-Control", "no-store")
|
||||
ctx.JSON(http.StatusCreated, gin.H{
|
||||
"evidence": deviceEvidenceResponse(result.Evidence),
|
||||
"replayed": result.Replayed,
|
||||
})
|
||||
}
|
||||
|
||||
func (handler *deviceHandlers) storeCandidates(ctx *gin.Context) {
|
||||
principal, ok := devicePrincipal(ctx)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
ExecutionID string `json:"execution_id"`
|
||||
ClaimGeneration int64 `json:"claim_generation"`
|
||||
TaskContentSHA256 string `json:"task_content_sha256"`
|
||||
ExecutionMode string `json:"execution_mode"`
|
||||
SearchQuery string `json:"search_query"`
|
||||
Provenance *usecase.ExecutionProvenance `json:"provenance"`
|
||||
Candidates []usecase.ExecutionCandidate `json:"candidates"`
|
||||
Recommendation *usecase.CandidateRecommendation `json:"recommendation"`
|
||||
}
|
||||
if !decodeDeviceJSON(ctx, &request) {
|
||||
return
|
||||
}
|
||||
replayed, err := handler.services.Results.StoreCandidates(
|
||||
ctx.Request.Context(),
|
||||
usecase.StoreExecutionCandidatesCommand{
|
||||
Identity: handler.executionResultIdentity(
|
||||
ctx, principal, request.ExecutionID, request.ClaimGeneration,
|
||||
),
|
||||
TaskContentSHA256: request.TaskContentSHA256,
|
||||
ExecutionMode: request.ExecutionMode,
|
||||
SearchQuery: request.SearchQuery,
|
||||
Provenance: request.Provenance,
|
||||
Candidates: request.Candidates,
|
||||
Recommendation: request.Recommendation,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
writeUsecaseError(ctx, err)
|
||||
return
|
||||
}
|
||||
ctx.Header("Cache-Control", "no-store")
|
||||
ctx.JSON(http.StatusOK, gin.H{"replayed": replayed})
|
||||
}
|
||||
|
||||
func (handler *deviceHandlers) completeTask(ctx *gin.Context) {
|
||||
principal, ok := devicePrincipal(ctx)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
ExecutionID string `json:"execution_id"`
|
||||
ClaimGeneration int64 `json:"claim_generation"`
|
||||
TaskContentSHA256 string `json:"task_content_sha256"`
|
||||
ExecutionMode string `json:"execution_mode"`
|
||||
Outcome string `json:"outcome"`
|
||||
OperatorReason string `json:"operator_reason"`
|
||||
Candidate *usecase.ExecutionCandidate `json:"candidate"`
|
||||
OrderSubmitted bool `json:"order_submitted"`
|
||||
}
|
||||
if !decodeDeviceJSON(ctx, &request) {
|
||||
return
|
||||
}
|
||||
result, replayed, err := handler.services.Results.Complete(
|
||||
ctx.Request.Context(),
|
||||
usecase.CompleteExecutionCommand{
|
||||
Identity: handler.executionResultIdentity(
|
||||
ctx, principal, request.ExecutionID, request.ClaimGeneration,
|
||||
),
|
||||
TaskContentSHA256: request.TaskContentSHA256,
|
||||
ExecutionMode: request.ExecutionMode,
|
||||
Outcome: request.Outcome,
|
||||
OperatorReason: request.OperatorReason,
|
||||
Candidate: request.Candidate,
|
||||
OrderSubmitted: request.OrderSubmitted,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
writeUsecaseError(ctx, err)
|
||||
return
|
||||
}
|
||||
ctx.Header("Cache-Control", "no-store")
|
||||
ctx.JSON(http.StatusOK, gin.H{
|
||||
"task": deviceTaskResponse(result),
|
||||
"replayed": replayed,
|
||||
})
|
||||
}
|
||||
|
||||
func (handler *deviceHandlers) failTask(ctx *gin.Context) {
|
||||
principal, ok := devicePrincipal(ctx)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
ExecutionID string `json:"execution_id"`
|
||||
ClaimGeneration int64 `json:"claim_generation"`
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Step string `json:"step"`
|
||||
Retryable bool `json:"retryable"`
|
||||
} `json:"error"`
|
||||
EvidenceAssetIDs []string `json:"evidence_asset_ids"`
|
||||
}
|
||||
if !decodeDeviceJSON(ctx, &request) {
|
||||
return
|
||||
}
|
||||
result, replayed, err := handler.services.Results.Fail(
|
||||
ctx.Request.Context(),
|
||||
usecase.FailExecutionCommand{
|
||||
Identity: handler.executionResultIdentity(
|
||||
ctx, principal, request.ExecutionID, request.ClaimGeneration,
|
||||
),
|
||||
ErrorCode: request.Error.Code,
|
||||
ErrorMessage: request.Error.Message,
|
||||
ErrorStep: request.Error.Step,
|
||||
Retryable: request.Error.Retryable,
|
||||
EvidenceAssetIDs: request.EvidenceAssetIDs,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
writeUsecaseError(ctx, err)
|
||||
return
|
||||
}
|
||||
ctx.Header("Cache-Control", "no-store")
|
||||
ctx.JSON(http.StatusOK, gin.H{
|
||||
"task": deviceTaskResponse(result),
|
||||
"replayed": replayed,
|
||||
})
|
||||
}
|
||||
|
||||
func (handler *deviceHandlers) executionResultIdentity(
|
||||
ctx *gin.Context,
|
||||
principal domain.AuthPrincipal,
|
||||
executionID string,
|
||||
claimGeneration int64,
|
||||
) usecase.ExecutionResultIdentity {
|
||||
return usecase.ExecutionResultIdentity{
|
||||
UserID: principal.UserID,
|
||||
DeviceID: principal.DeviceID,
|
||||
TaskID: ctx.Param("id"),
|
||||
ExecutionID: executionID,
|
||||
ClaimGeneration: claimGeneration,
|
||||
ClaimToken: ctx.GetHeader(claimTokenHeader),
|
||||
IdempotencyKey: ctx.GetHeader("Idempotency-Key"),
|
||||
}
|
||||
}
|
||||
|
||||
func isEvidenceMediaType(value string) bool {
|
||||
return hasEvidenceMediaType(value, "image/jpeg") ||
|
||||
hasEvidenceMediaType(value, "image/png") ||
|
||||
hasEvidenceMediaType(value, "image/webp")
|
||||
}
|
||||
|
||||
func hasEvidenceMediaType(value string, expected string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(strings.Split(value, ";")[0]), expected)
|
||||
}
|
||||
|
||||
func deviceEvidenceResponse(evidence domain.ExecutionEvidenceAsset) gin.H {
|
||||
return gin.H{
|
||||
"id": evidence.ID,
|
||||
"media_type": evidence.MediaType,
|
||||
"size_bytes": evidence.SizeBytes,
|
||||
"sha256": evidence.SHA256,
|
||||
"created_at": formatTime(evidence.CreatedAt),
|
||||
"received_after_execution_expiry": evidence.ReceivedAfterExecutionExpiry,
|
||||
}
|
||||
}
|
||||
|
||||
type lifecycleTransitionRequest struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
ClaimGeneration int64 `json:"claim_generation"`
|
||||
|
||||
@@ -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