feat(t214): persist candidate observation identities
This commit is contained in:
@@ -522,7 +522,7 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
|
||||
})
|
||||
requireDeviceStatus(t, events, http.StatusOK)
|
||||
|
||||
evidence := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
detailEvidence := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/evidence",
|
||||
contentType: "image/jpeg",
|
||||
@@ -533,15 +533,42 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
|
||||
executionID: started.Execution.ID,
|
||||
claimGeneration: started.Task.ClaimGeneration,
|
||||
})
|
||||
requireDeviceStatus(t, evidence, http.StatusCreated)
|
||||
requireDeviceStatus(t, detailEvidence, http.StatusCreated)
|
||||
var evidenceResponse struct {
|
||||
Evidence struct {
|
||||
ID string `json:"id"`
|
||||
ID string `json:"id"`
|
||||
SHA256 string `json:"sha256"`
|
||||
} `json:"evidence"`
|
||||
}
|
||||
decodeResponse(t, evidence, &evidenceResponse)
|
||||
if evidenceResponse.Evidence.ID == "" {
|
||||
t.Fatalf("evidence response = %s", evidence.Body.String())
|
||||
decodeResponse(t, detailEvidence, &evidenceResponse)
|
||||
if evidenceResponse.Evidence.ID == "" || evidenceResponse.Evidence.SHA256 == "" {
|
||||
t.Fatalf("detail evidence response = %s", detailEvidence.Body.String())
|
||||
}
|
||||
specificationEvidence := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/evidence",
|
||||
contentType: "image/jpeg",
|
||||
body: deviceReferenceImage(t, 702),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
idempotencyKey: "result-evidence-2",
|
||||
executionID: started.Execution.ID,
|
||||
claimGeneration: started.Task.ClaimGeneration,
|
||||
})
|
||||
requireDeviceStatus(t, specificationEvidence, http.StatusCreated)
|
||||
var specificationEvidenceResponse struct {
|
||||
Evidence struct {
|
||||
ID string `json:"id"`
|
||||
SHA256 string `json:"sha256"`
|
||||
} `json:"evidence"`
|
||||
}
|
||||
decodeResponse(t, specificationEvidence, &specificationEvidenceResponse)
|
||||
if specificationEvidenceResponse.Evidence.ID == "" ||
|
||||
specificationEvidenceResponse.Evidence.SHA256 == "" {
|
||||
t.Fatalf(
|
||||
"specification evidence response = %s",
|
||||
specificationEvidence.Body.String(),
|
||||
)
|
||||
}
|
||||
|
||||
detail, err := fixture.tasks.Get(context.Background(), "local-admin", taskID)
|
||||
@@ -549,13 +576,36 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
|
||||
t.Fatalf("get task for content hash: %v", err)
|
||||
}
|
||||
taskHash := usecase.TaskContentSHA256(detail.Task)
|
||||
cardSignature := strings.Repeat("b", 64)
|
||||
detailSignature := strings.Repeat("c", 64)
|
||||
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}]}`,
|
||||
`{"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":"","image_url":"","card_signature":%q,"detail_signature":%q,"detail_evidence_sha256":%q,"specification_evidence_sha256":%q,"evidence_asset_ids":[%q,%q],"evaluation":null}]}`,
|
||||
started.Execution.ID,
|
||||
started.Task.ClaimGeneration,
|
||||
taskHash,
|
||||
cardSignature,
|
||||
detailSignature,
|
||||
evidenceResponse.Evidence.SHA256,
|
||||
specificationEvidenceResponse.Evidence.SHA256,
|
||||
evidenceResponse.Evidence.ID,
|
||||
specificationEvidenceResponse.Evidence.ID,
|
||||
)
|
||||
badCandidatePayload := strings.Replace(
|
||||
candidatePayload,
|
||||
evidenceResponse.Evidence.SHA256,
|
||||
strings.Repeat("0", 64),
|
||||
1,
|
||||
)
|
||||
badCandidates := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/candidates",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(badCandidatePayload),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
idempotencyKey: "result-candidates-bad-hash",
|
||||
})
|
||||
requireDeviceStatus(t, badCandidates, http.StatusConflict)
|
||||
candidates := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/candidates",
|
||||
@@ -566,6 +616,19 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
|
||||
idempotencyKey: "result-candidates-1",
|
||||
})
|
||||
requireDeviceStatus(t, candidates, http.StatusOK)
|
||||
candidateReplay := 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, candidateReplay, http.StatusOK)
|
||||
if !strings.Contains(candidateReplay.Body.String(), `"replayed":true`) {
|
||||
t.Fatalf("candidate replay response = %s", candidateReplay.Body.String())
|
||||
}
|
||||
|
||||
humanReviewPayload := fmt.Sprintf(
|
||||
`{"execution_id":%q,"claim_generation":%d,"task_content_sha256":%q,"reason_schema_version":1,"outcome":"CANDIDATE_ACCEPTED","selected_candidate_ordinal":1,"primary_reason_code":"SELECTED_BEST_MATCH","note":"","items":[{"candidate_ordinal":1,"label":"ACCEPT","primary_reason_code":"SKU_MATCH","reason_codes":["SKU_MATCH"],"note":""}]}`,
|
||||
@@ -633,15 +696,20 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
|
||||
t.Fatalf("migration.New() after review error = %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err == nil {
|
||||
t.Fatal("candidate migration down succeeded with retained review data")
|
||||
t.Fatal("candidate identity migration down succeeded with retained data")
|
||||
}
|
||||
|
||||
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}`,
|
||||
`{"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}`,
|
||||
started.Execution.ID,
|
||||
started.Task.ClaimGeneration,
|
||||
taskHash,
|
||||
cardSignature,
|
||||
detailSignature,
|
||||
evidenceResponse.Evidence.SHA256,
|
||||
specificationEvidenceResponse.Evidence.SHA256,
|
||||
evidenceResponse.Evidence.ID,
|
||||
specificationEvidenceResponse.Evidence.ID,
|
||||
)
|
||||
complete := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
@@ -677,7 +745,7 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
|
||||
detail.Report.Outcome == nil ||
|
||||
detail.Report.Outcome.OrderSubmitted ||
|
||||
len(detail.Report.Events) != 1 ||
|
||||
len(detail.Report.EvidenceAssets) != 1 ||
|
||||
len(detail.Report.EvidenceAssets) != 2 ||
|
||||
detail.Report.CandidateBatch == nil ||
|
||||
detail.Report.DecisionDataset == nil ||
|
||||
len(detail.Report.DecisionDataset.Observations) != 1 ||
|
||||
@@ -686,6 +754,16 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
|
||||
detail.Report.DecisionDataset.HumanReviews[1].Version != 2 {
|
||||
t.Fatalf("execution report = %+v", detail.Report)
|
||||
}
|
||||
identity := detail.Report.DecisionDataset.Observations[0].Identity
|
||||
if identity == nil ||
|
||||
len(identity.CandidateKey) != 64 ||
|
||||
identity.CardSignature != cardSignature ||
|
||||
identity.DetailSignature != detailSignature ||
|
||||
identity.DetailEvidenceSHA256 != evidenceResponse.Evidence.SHA256 ||
|
||||
identity.SpecificationEvidenceSHA256 !=
|
||||
specificationEvidenceResponse.Evidence.SHA256 {
|
||||
t.Fatalf("candidate identity = %+v", identity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceReleaseReturnsClaimedTaskToPending(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user