From be900b241154a5858bdf67caf0d69f4ed266135c Mon Sep 17 00:00:00 2001 From: chengma Date: Mon, 17 Aug 2026 18:34:35 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E5=A2=9E=E5=8A=A0=E9=87=87=E8=B4=AD?= =?UTF-8?q?=E8=A7=84=E6=A0=BC=E8=B7=A8=E7=AB=AF=E8=81=94=E5=90=88=E9=AA=8C?= =?UTF-8?q?=E6=94=B6=20(#258)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../purchase_spec_resolution_contract_test.go | 343 +++++++++++++++ .../purchase_spec_resolution_contract_test.go | 265 ++++++++++++ ...rchase_spec_resolution_contract_vectors.py | 399 ++++++++++++++++++ docs/admin/06-quality-security.md | 30 ++ docs/client/06-quality-security.md | 25 ++ .../purchase_spec_resolution_v1.json | 173 ++++++++ 6 files changed, 1235 insertions(+) create mode 100644 admin/handler/api/purchase_spec_resolution_contract_test.go create mode 100644 admin/service/purchase_spec_resolution_contract_test.go create mode 100644 client/test/test_purchase_spec_resolution_contract_vectors.py create mode 100644 testdata/contracts/purchase_spec_resolution_v1.json diff --git a/admin/handler/api/purchase_spec_resolution_contract_test.go b/admin/handler/api/purchase_spec_resolution_contract_test.go new file mode 100644 index 0000000..3d1953f --- /dev/null +++ b/admin/handler/api/purchase_spec_resolution_contract_test.go @@ -0,0 +1,343 @@ +package api + +import ( + "bytes" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/gin-gonic/gin" + + "cmautobuy/admin/model" + "cmautobuy/admin/repository" + "cmautobuy/admin/service" +) + +type apiSpecContractFixture struct { + Contract string `json:"contract"` + SchemaVersion int `json:"schema_version"` + Defaults apiSpecContractDefaults `json:"defaults"` + Cases []apiSpecContractCase `json:"cases"` + ErrorCases []apiSpecContractErrorCase `json:"error_cases"` +} + +type apiSpecContractDefaults struct { + ClientID string `json:"client_id"` + TaskVersion int64 `json:"task_version"` + PDDGoodsID string `json:"pdd_goods_id"` + SelectedColor string `json:"selected_color"` + AttemptID string `json:"attempt_id"` + ObservedAt string `json:"observed_at"` +} + +type apiSpecContractCase struct { + Name string `json:"name"` + TaskID string `json:"task_id"` + TargetSize string `json:"target_size"` + Candidates []string `json:"candidates"` + CandidateSnapshotHash string `json:"candidate_snapshot_hash"` + IdempotencyKey string `json:"idempotency_key"` + Mode string `json:"mode"` +} + +type apiSpecContractErrorCase struct { + Name string `json:"name"` + BaseCase string `json:"base_case"` + Mutation string `json:"mutation"` + ExpectedHTTPStatus int `json:"expected_http_status"` + ExpectedErrorCode string `json:"expected_error_code"` +} + +func TestPurchaseSpecResolutionContractVectors_AdminHTTPRuleReplay(t *testing.T) { + fixture := loadAPISpecContractFixture(t) + var ruleCase apiSpecContractCase + for _, candidate := range fixture.Cases { + if candidate.Name == "rule_weight_unique" { + ruleCase = candidate + break + } + } + if ruleCase.Name == "" { + t.Fatal("共享向量缺少 rule_weight_unique") + } + db := newAPISpecContractDB(t) + prepareAPISpecContractTask(t, db, fixture.Defaults, ruleCase, model.TaskPurchase, true) + router := newAPISpecContractRouter(db) + requestBody, key := buildAPISpecContractRequest(fixture.Defaults, ruleCase) + + first := performAPISpecContractRequest(router, ruleCase.TaskID, fixture.Defaults.ClientID, key, requestBody) + second := performAPISpecContractRequest(router, ruleCase.TaskID, fixture.Defaults.ClientID, key, requestBody) + if first.Code != http.StatusOK || second.Code != http.StatusOK || first.Body.String() != second.Body.String() { + t.Fatalf("HTTP 幂等响应不一致 first=%d %s second=%d %s", + first.Code, first.Body.String(), second.Code, second.Body.String()) + } + var response map[string]any + if err := json.Unmarshal(first.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + match, _ := response["match"].(map[string]any) + if response["outcome"] != "matched" || response["source"] != "rule" || match["candidate_id"] != "c2" { + t.Fatalf("HTTP 规则响应=%v", response) + } +} + +func TestPurchaseSpecResolutionContractVectors_AdminHTTPErrors(t *testing.T) { + fixture := loadAPISpecContractFixture(t) + caseByName := make(map[string]apiSpecContractCase, len(fixture.Cases)) + for _, testCase := range fixture.Cases { + caseByName[testCase.Name] = testCase + } + for _, errorCase := range fixture.ErrorCases { + t.Run(errorCase.Name, func(t *testing.T) { + base, ok := caseByName[errorCase.BaseCase] + if !ok { + t.Fatalf("未知基础向量 %q", errorCase.BaseCase) + } + db := newAPISpecContractDB(t) + taskType := model.TaskPurchase + claimed := true + if errorCase.Mutation == "task_not_purchase" { + taskType = model.TaskCollect + } + if errorCase.Mutation == "task_not_claimed" { + claimed = false + } + if errorCase.Mutation != "task_not_found" { + prepareAPISpecContractTask(t, db, fixture.Defaults, base, taskType, claimed) + } + router := newAPISpecContractRouter(db) + body, key := buildAPISpecContractRequest(fixture.Defaults, base) + taskID := base.TaskID + applyAPISpecContractMutation(errorCase.Mutation, &taskID, body, &key) + + if errorCase.Mutation == "idempotency_conflict" { + original, originalKey := buildAPISpecContractRequest(fixture.Defaults, base) + first := performAPISpecContractRequest(router, base.TaskID, fixture.Defaults.ClientID, + originalKey, original) + if first.Code != http.StatusOK { + t.Fatalf("冲突向量首请求失败 status=%d body=%s", first.Code, first.Body.String()) + } + } + + recorder := performAPISpecContractRequest(router, taskID, fixture.Defaults.ClientID, key, body) + if recorder.Code != errorCase.ExpectedHTTPStatus { + t.Fatalf("status=%d expected=%d body=%s", recorder.Code, + errorCase.ExpectedHTTPStatus, recorder.Body.String()) + } + var response struct { + Error struct { + Code string `json:"code"` + } `json:"error"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if response.Error.Code != errorCase.ExpectedErrorCode { + t.Fatalf("error.code=%s expected=%s body=%s", response.Error.Code, + errorCase.ExpectedErrorCode, recorder.Body.String()) + } + }) + } +} + +func loadAPISpecContractFixture(t *testing.T) apiSpecContractFixture { + t.Helper() + data, err := os.ReadFile(filepath.Join("..", "..", "..", "testdata", "contracts", + "purchase_spec_resolution_v1.json")) + if err != nil { + t.Fatal(err) + } + var fixture apiSpecContractFixture + if err := json.Unmarshal(data, &fixture); err != nil { + t.Fatal(err) + } + if fixture.Contract != "purchase-spec-resolution-v1" || fixture.SchemaVersion != 1 { + t.Fatalf("共享契约向量头无效: %+v", fixture) + } + return fixture +} + +func buildAPISpecContractRequest(defaults apiSpecContractDefaults, + testCase apiSpecContractCase) (map[string]any, string) { + candidates := make([]any, 0, len(testCase.Candidates)) + for index, value := range testCase.Candidates { + candidates = append(candidates, map[string]any{ + "candidate_id": "c" + strconv.Itoa(index+1), + "raw_text": value, + "options": map[string]any{"color": defaults.SelectedColor, "size": value}, + }) + } + request := map[string]any{ + "schema_version": 1, + "task_version": defaults.TaskVersion, + "attempt_id": defaults.AttemptID, + "pdd_goods_id": defaults.PDDGoodsID, + "original_options": map[string]any{"color": defaults.SelectedColor, "size": testCase.TargetSize}, + "selected_color": defaults.SelectedColor, + "target_size": testCase.TargetSize, + "candidates": candidates, + "candidate_snapshot_hash": testCase.CandidateSnapshotHash, + "observed_at": defaults.ObservedAt, + } + if actual := apiSpecSnapshotHash(request); actual != testCase.CandidateSnapshotHash { + panic(fmt.Sprintf("共享向量候选哈希错误 actual=%s expected=%s", actual, testCase.CandidateSnapshotHash)) + } + if actual := apiSpecIdempotencyKey(testCase.TaskID, request); actual != testCase.IdempotencyKey { + panic(fmt.Sprintf("共享向量幂等键错误 actual=%s expected=%s", actual, testCase.IdempotencyKey)) + } + return request, testCase.IdempotencyKey +} + +func applyAPISpecContractMutation(mutation string, taskID *string, request map[string]any, key *string) { + switch mutation { + case "schema_version_2": + request["schema_version"] = 2 + case "candidate_id_gap": + request["candidates"].([]any)[0].(map[string]any)["candidate_id"] = "c2" + apiSpecRefreshHashes(*taskID, request, key) + case "snapshot_hash_mismatch": + request["candidate_snapshot_hash"] = strings.Repeat("0", 64) + *key = apiSpecIdempotencyKey(*taskID, request) + case "selected_color_not_claimed": + request["selected_color"] = "测试白" + for _, raw := range request["candidates"].([]any) { + raw.(map[string]any)["options"].(map[string]any)["color"] = "测试白" + } + apiSpecRefreshHashes(*taskID, request, key) + case "target_size_not_claimed": + request["target_size"] = "70公斤" + case "observed_at_without_timezone": + request["observed_at"] = "2026-08-17T08:00:00" + case "attempt_id_too_long": + request["attempt_id"] = strings.Repeat("a", 192) + *key = apiSpecIdempotencyKey(*taskID, request) + case "body_too_large": + request["future_padding"] = strings.Repeat("x", 64<<10) + case "task_not_found": + *taskID = "cg-contract-missing" + *key = apiSpecIdempotencyKey(*taskID, request) + case "task_not_purchase", "task_not_claimed": + case "task_version_conflict": + request["task_version"] = float64(4) + case "pdd_goods_mismatch": + request["pdd_goods_id"] = "PDD-CONTRACT-OTHER" + apiSpecRefreshHashes(*taskID, request, key) + case "idempotency_conflict": + request["observed_at"] = "2026-08-17T08:00:01Z" + default: + panic("未知契约变体: " + mutation) + } +} + +func apiSpecRefreshHashes(taskID string, request map[string]any, key *string) { + request["candidate_snapshot_hash"] = apiSpecSnapshotHash(request) + *key = apiSpecIdempotencyKey(taskID, request) +} + +func apiSpecSnapshotHash(request map[string]any) string { + values := []string{"spec-resolution-v1", request["pdd_goods_id"].(string), + request["selected_color"].(string), strconv.Itoa(len(request["candidates"].([]any)))} + for _, raw := range request["candidates"].([]any) { + candidate := raw.(map[string]any) + options := candidate["options"].(map[string]any) + values = append(values, candidate["candidate_id"].(string), candidate["raw_text"].(string), + options["color"].(string), options["size"].(string)) + } + return apiSpecSHA256Frames(values...) +} + +func apiSpecIdempotencyKey(taskID string, request map[string]any) string { + return "spec-resolution-v1:" + apiSpecSHA256Frames(taskID, request["attempt_id"].(string), + request["candidate_snapshot_hash"].(string), "spec-resolution-v1") +} + +func apiSpecSHA256Frames(values ...string) string { + var material strings.Builder + for _, value := range values { + material.WriteString(strconv.Itoa(len([]byte(value)))) + material.WriteByte(':') + material.WriteString(value) + } + sum := sha256.Sum256([]byte(material.String())) + return hex.EncodeToString(sum[:]) +} + +func newAPISpecContractDB(t *testing.T) *sql.DB { + t.Helper() + db, err := repository.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + if err := repository.Migrate(db); err != nil { + t.Fatal(err) + } + _, err = db.Exec(`CREATE TABLE purchase_spec_resolutions ( + resolution_id TEXT PRIMARY KEY,task_id TEXT NOT NULL,attempt_id TEXT NOT NULL,client_id TEXT NOT NULL, + task_version INTEGER NOT NULL,pdd_goods_id TEXT NOT NULL,original_options_json TEXT NOT NULL, + selected_color TEXT NOT NULL,target_size TEXT NOT NULL,candidates_json TEXT NOT NULL, + candidate_snapshot_hash TEXT NOT NULL,request_hash TEXT NOT NULL,observed_at TEXT NOT NULL, + outcome TEXT NOT NULL DEFAULT 'pending',decision_source TEXT,chosen_candidate_id TEXT, + resolved_options_json TEXT,confidence_bps INTEGER,reason TEXT,provider_id TEXT,source_model TEXT, + config_fingerprint TEXT,rules_version TEXT,prompt_version TEXT,created_at TEXT NOT NULL,decided_at TEXT, + UNIQUE(task_id,attempt_id,candidate_snapshot_hash) + )`) + if err != nil { + t.Fatal(err) + } + return db +} + +func prepareAPISpecContractTask(t *testing.T, db *sql.DB, defaults apiSpecContractDefaults, + testCase apiSpecContractCase, taskType model.TaskType, claimed bool) { + t.Helper() + now := model.NowISO() + if _, err := db.Exec(`INSERT INTO clients + (client_id,name,device_address,platform,pdd_package,capabilities,last_seen_at,created_at,updated_at) + VALUES(?,?,'','android','','{}',?,?,?)`, defaults.ClientID, "契约测试客户端", now, now, now); err != nil { + t.Fatal(err) + } + options, _ := json.Marshal(map[string]string{"color": defaults.SelectedColor, "size": testCase.TargetSize}) + if _, err := db.Exec(`INSERT INTO tasks + (task_id,task_type,status,version,assigned_client,goods_id,pdd_goods_url,pdd_goods_id,pdd_options, + quantity,max_price_cent,created_at,updated_at) + VALUES(?,?,'claimed',?,?,?,?,?,?,1,2000,?,?)`, testCase.TaskID, taskType, defaults.TaskVersion, + defaults.ClientID, "SHOPEE-CONTRACT", "https://example.invalid/contract-goods", defaults.PDDGoodsID, + string(options), now, now); err != nil { + t.Fatal(err) + } + if claimed { + if _, err := db.Exec(`INSERT INTO task_claims(task_id,client_id,claimed_at) VALUES(?,?,?)`, + testCase.TaskID, defaults.ClientID, now); err != nil { + t.Fatal(err) + } + } +} + +func newAPISpecContractRouter(db *sql.DB) *gin.Engine { + gin.SetMode(gin.TestMode) + router := gin.New() + Register(router, db, nil, service.AIEndpointPolicy{}) + return router +} + +func performAPISpecContractRequest(router *gin.Engine, taskID, clientID, key string, + body map[string]any) *httptest.ResponseRecorder { + encoded, _ := json.Marshal(body) + request := httptest.NewRequest(http.MethodPost, + "/api/v1/client/tasks/"+taskID+"/spec-resolution", bytes.NewReader(encoded)) + request.Header.Set("X-Client-Id", clientID) + request.Header.Set("Idempotency-Key", key) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + return recorder +} diff --git a/admin/service/purchase_spec_resolution_contract_test.go b/admin/service/purchase_spec_resolution_contract_test.go new file mode 100644 index 0000000..106151c --- /dev/null +++ b/admin/service/purchase_spec_resolution_contract_test.go @@ -0,0 +1,265 @@ +package service + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "os" + "path/filepath" + "strconv" + "sync/atomic" + "testing" + + "cmautobuy/admin/model" +) + +type purchaseSpecContractFixture struct { + Contract string `json:"contract"` + SchemaVersion int `json:"schema_version"` + Defaults purchaseSpecContractDefaults `json:"defaults"` + Cases []purchaseSpecContractCase `json:"cases"` + ErrorCases []purchaseSpecContractErrorCase `json:"error_cases"` +} + +type purchaseSpecContractDefaults struct { + ClientID string `json:"client_id"` + TaskType string `json:"task_type"` + TaskVersion int64 `json:"task_version"` + PDDGoodsID string `json:"pdd_goods_id"` + SelectedColor string `json:"selected_color"` + AttemptID string `json:"attempt_id"` + ObservedAt string `json:"observed_at"` + AIConfidenceThresholdBPS int `json:"ai_confidence_threshold_bps"` +} + +type purchaseSpecContractCase struct { + Name string `json:"name"` + TaskID string `json:"task_id"` + TargetSize string `json:"target_size"` + Candidates []string `json:"candidates"` + CandidateSnapshotHash string `json:"candidate_snapshot_hash"` + IdempotencyKey string `json:"idempotency_key"` + Mode string `json:"mode"` + Model *purchaseSpecContractModel `json:"model"` + Expected purchaseSpecContractExpected `json:"expected"` +} + +type purchaseSpecContractModel struct { + Conclusion string `json:"conclusion"` + CandidateID string `json:"candidate_id"` + ConfidenceBPS int `json:"confidence_bps"` + Reason string `json:"reason"` + MissingDimensions []string `json:"missing_dimensions"` + Error string `json:"error"` +} + +type purchaseSpecContractExpected struct { + Outcome model.PurchaseSpecResolutionOutcome `json:"outcome"` + Source model.PurchaseSpecResolutionSource `json:"source"` + CandidateID *string `json:"candidate_id"` + ConfidenceBPS *int `json:"confidence_bps"` + ModelCalls int `json:"model_calls"` +} + +type purchaseSpecContractErrorCase struct { + Name string `json:"name"` + BaseCase string `json:"base_case"` + Mutation string `json:"mutation"` + ExpectedHTTPStatus int `json:"expected_http_status"` + ExpectedErrorCode string `json:"expected_error_code"` +} + +type contractAIModelClient struct { + response AIModelMatchResponse + err error + calls atomic.Int32 +} + +func (client *contractAIModelClient) Match(context.Context, model.AIProviderConfig, string, + AIModelMatchRequest) (AIModelMatchResponse, error) { + client.calls.Add(1) + return client.response, client.err +} + +func TestPurchaseSpecResolutionContractVectors_AdminDecisionAndReplay(t *testing.T) { + fixture := loadPurchaseSpecContractFixture(t) + for _, testCase := range fixture.Cases { + t.Run(testCase.Name, func(t *testing.T) { + db := newRuntimeSpecTestDB(t) + preparePurchaseSpecContractTask(t, db, fixture.Defaults, testCase) + req, body := buildPurchaseSpecContractRequest(fixture.Defaults, testCase) + if actual := purchaseSpecCandidateSnapshotHash(req); actual != testCase.CandidateSnapshotHash { + t.Fatalf("候选哈希=%s,向量=%s", actual, testCase.CandidateSnapshotHash) + } + if actual := purchaseSpecIdempotencyKey(testCase.TaskID, req); actual != testCase.IdempotencyKey { + t.Fatalf("幂等键=%s,向量=%s", actual, testCase.IdempotencyKey) + } + + client := contractAIClientForCase(testCase) + var loaderCalls atomic.Int32 + loader := func(context.Context, *sql.DB, AISecretStore, AIEndpointPolicy) (AIMatchSnapshot, error) { + loaderCalls.Add(1) + if testCase.Mode != "ai" { + return AIMatchSnapshot{}, errors.New("规则向量不应加载 AI") + } + return AIMatchSnapshot{ + Provider: model.AIProviderConfig{ + ProviderID: "contract-provider", + Model: "contract-model", + ConfidenceThresholdBPS: fixture.Defaults.AIConfidenceThresholdBPS, + }, + ConfigFingerprint: "contract-fingerprint", + Secret: "not-a-real-secret", + Client: client, + }, nil + } + + first, err := resolvePurchaseSpecWithSnapshotLoader(context.Background(), db, nil, + AIEndpointPolicy{}, testCase.TaskID, fixture.Defaults.ClientID, + testCase.IdempotencyKey, body, loader) + if err != nil { + t.Fatal(err) + } + second, err := resolvePurchaseSpecWithSnapshotLoader(context.Background(), db, nil, + AIEndpointPolicy{}, testCase.TaskID, fixture.Defaults.ClientID, + testCase.IdempotencyKey, body, loader) + if err != nil || second != first { + t.Fatalf("幂等重放不一致 first=%s second=%s err=%v", first, second, err) + } + + var response PurchaseSpecResolutionResponse + if err := json.Unmarshal([]byte(first), &response); err != nil { + t.Fatal(err) + } + assertPurchaseSpecContractResponse(t, response, testCase) + if int(client.calls.Load()) != testCase.Expected.ModelCalls { + t.Fatalf("模型调用=%d,期望=%d", client.calls.Load(), testCase.Expected.ModelCalls) + } + if testCase.Mode == "rule" && loaderCalls.Load() != 0 { + t.Fatalf("规则唯一/冲突向量不应加载 AI,实际=%d", loaderCalls.Load()) + } + var records, decisions, idempotency int + if err := db.QueryRow(`SELECT COUNT(*),SUM(outcome<>'pending') FROM purchase_spec_resolutions WHERE task_id=?`, + testCase.TaskID).Scan(&records, &decisions); err != nil { + t.Fatal(err) + } + if err := db.QueryRow(`SELECT COUNT(*) FROM idempotency_keys WHERE `+"`key`"+`=?`, + testCase.IdempotencyKey).Scan(&idempotency); err != nil { + t.Fatal(err) + } + if records != 1 || decisions != 1 || idempotency != 1 { + t.Fatalf("records=%d decisions=%d idempotency=%d", records, decisions, idempotency) + } + }) + } +} + +func loadPurchaseSpecContractFixture(t *testing.T) purchaseSpecContractFixture { + t.Helper() + path := filepath.Join("..", "..", "testdata", "contracts", "purchase_spec_resolution_v1.json") + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var fixture purchaseSpecContractFixture + if err := json.Unmarshal(data, &fixture); err != nil { + t.Fatal(err) + } + if fixture.Contract != "purchase-spec-resolution-v1" || fixture.SchemaVersion != 1 || len(fixture.Cases) == 0 { + t.Fatalf("共享契约向量头无效: %+v", fixture) + } + return fixture +} + +func buildPurchaseSpecContractRequest(defaults purchaseSpecContractDefaults, + testCase purchaseSpecContractCase) (PurchaseSpecResolutionRequest, []byte) { + req := PurchaseSpecResolutionRequest{ + SchemaVersion: 1, + TaskVersion: defaults.TaskVersion, + AttemptID: defaults.AttemptID, + PddGoodsID: defaults.PDDGoodsID, + OriginalOptions: map[string]string{"color": defaults.SelectedColor, "size": testCase.TargetSize}, + SelectedColor: defaults.SelectedColor, + TargetSize: testCase.TargetSize, + ObservedAt: defaults.ObservedAt, + } + for index, value := range testCase.Candidates { + req.Candidates = append(req.Candidates, PurchaseSpecCandidate{ + CandidateID: "c" + strconv.Itoa(index+1), + RawText: value, + Options: map[string]string{"color": defaults.SelectedColor, "size": value}, + }) + } + req.CandidateSnapshotHash = testCase.CandidateSnapshotHash + body, _ := json.Marshal(req) + return req, body +} + +func preparePurchaseSpecContractTask(t *testing.T, db *sql.DB, defaults purchaseSpecContractDefaults, + testCase purchaseSpecContractCase) { + t.Helper() + now := model.NowISO() + if _, err := db.Exec(`INSERT INTO clients + (client_id,name,device_address,platform,pdd_package,capabilities,last_seen_at,created_at,updated_at) + VALUES(?,?,'','android','','{}',?,?,?)`, defaults.ClientID, "契约测试客户端", now, now, now); err != nil { + t.Fatal(err) + } + options, _ := json.Marshal(map[string]string{"color": defaults.SelectedColor, "size": testCase.TargetSize}) + if _, err := db.Exec(`INSERT INTO tasks + (task_id,task_type,status,version,assigned_client,goods_id,pdd_goods_url,pdd_goods_id,pdd_options, + quantity,max_price_cent,created_at,updated_at) + VALUES(?,?,'claimed',?,?,?, ?,?,?,1,2000,?,?)`, + testCase.TaskID, model.TaskPurchase, defaults.TaskVersion, defaults.ClientID, "SHOPEE-CONTRACT", + "https://example.invalid/contract-goods", defaults.PDDGoodsID, string(options), now, now); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`INSERT INTO task_claims(task_id,client_id,claimed_at) VALUES(?,?,?)`, + testCase.TaskID, defaults.ClientID, now); err != nil { + t.Fatal(err) + } +} + +func contractAIClientForCase(testCase purchaseSpecContractCase) *contractAIModelClient { + client := &contractAIModelClient{} + if testCase.Model == nil { + return client + } + if testCase.Model.Error == "timeout" { + client.err = context.DeadlineExceeded + return client + } + client.response = AIModelMatchResponse{ + Conclusion: testCase.Model.Conclusion, + CandidateID: testCase.Model.CandidateID, + ConfidenceBPS: testCase.Model.ConfidenceBPS, + Reason: testCase.Model.Reason, + MissingDimensions: append([]string(nil), testCase.Model.MissingDimensions...), + } + return client +} + +func assertPurchaseSpecContractResponse(t *testing.T, response PurchaseSpecResolutionResponse, + testCase purchaseSpecContractCase) { + t.Helper() + if response.Outcome != testCase.Expected.Outcome || response.CandidateSnapshotHash != testCase.CandidateSnapshotHash { + t.Fatalf("响应结论=%s hash=%s", response.Outcome, response.CandidateSnapshotHash) + } + if response.Source == nil || *response.Source != testCase.Expected.Source { + t.Fatalf("响应来源=%v,期望=%s", response.Source, testCase.Expected.Source) + } + if testCase.Expected.CandidateID == nil { + if response.Match != nil { + t.Fatalf("非 matched 不应返回候选: %+v", response.Match) + } + } else if response.Match == nil || response.Match.CandidateID != *testCase.Expected.CandidateID { + t.Fatalf("候选=%+v,期望=%s", response.Match, *testCase.Expected.CandidateID) + } + if testCase.Expected.ConfidenceBPS == nil { + if response.ConfidenceBPS != nil { + t.Fatalf("置信度=%v,期望 null", response.ConfidenceBPS) + } + } else if response.ConfidenceBPS == nil || *response.ConfidenceBPS != *testCase.Expected.ConfidenceBPS { + t.Fatalf("置信度=%v,期望=%d", response.ConfidenceBPS, *testCase.Expected.ConfidenceBPS) + } +} diff --git a/client/test/test_purchase_spec_resolution_contract_vectors.py b/client/test/test_purchase_spec_resolution_contract_vectors.py new file mode 100644 index 0000000..fbc1295 --- /dev/null +++ b/client/test/test_purchase_spec_resolution_contract_vectors.py @@ -0,0 +1,399 @@ +"""Admin、HTTP Gateway 与 Mock 共用的运行时规格解析契约向量。""" + +import hashlib +import io +import json +import unittest +from copy import deepcopy +from pathlib import Path +from urllib.error import HTTPError + +from src.admin_gateway import ( + AdminGatewayError, + AdminTask, + AndroidDeviceInfo, + ClaimCapabilities, + ClientInfo, + SpecResolutionMatch, + SpecResolutionReceipt, +) +from src.http_admin_gateway import HttpAdminGateway +from src.mock_admin_gateway import MockAdminGateway +from src.task_models import TaskType + + +CONTRACT_PATH = ( + Path(__file__).resolve().parents[2] + / "testdata" + / "contracts" + / "purchase_spec_resolution_v1.json" +) + + +class _FakeResponse: + def __init__(self, status: int, payload: dict): + self.status = status + self._body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + + def getcode(self): + return self.status + + def read(self): + return self._body + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + +class _RecordingOpener: + def __init__(self, response): + self.response = response + self.request = None + + def __call__(self, request, timeout): + self.request = request + if isinstance(self.response, Exception): + raise self.response + return self.response + + +def _frame(value: str) -> str: + return f"{len(value.encode('utf-8'))}:{value}" + + +def _snapshot_hash(request: dict) -> str: + values = [ + "spec-resolution-v1", + request["pdd_goods_id"], + request["selected_color"], + str(len(request["candidates"])), + ] + for candidate in request["candidates"]: + values.extend( + ( + candidate["candidate_id"], + candidate["raw_text"], + candidate["options"]["color"], + candidate["options"]["size"], + ) + ) + return hashlib.sha256( + "".join(_frame(value) for value in values).encode("utf-8") + ).hexdigest() + + +def _idempotency_key(task_id: str, request: dict) -> str: + values = ( + task_id, + request["attempt_id"], + request["candidate_snapshot_hash"], + "spec-resolution-v1", + ) + return "spec-resolution-v1:" + hashlib.sha256( + "".join(_frame(value) for value in values).encode("utf-8") + ).hexdigest() + + +def _build_request(defaults: dict, case: dict) -> tuple[dict, str]: + candidates = [ + { + "candidate_id": f"c{index}", + "raw_text": value, + "options": { + "color": defaults["selected_color"], + "size": value, + }, + } + for index, value in enumerate(case["candidates"], start=1) + ] + request = { + "schema_version": 1, + "task_version": defaults["task_version"], + "attempt_id": defaults["attempt_id"], + "pdd_goods_id": defaults["pdd_goods_id"], + "original_options": { + "color": defaults["selected_color"], + "size": case["target_size"], + }, + "selected_color": defaults["selected_color"], + "target_size": case["target_size"], + "candidates": candidates, + "candidate_snapshot_hash": case["candidate_snapshot_hash"], + "observed_at": defaults["observed_at"], + } + if _snapshot_hash(request) != case["candidate_snapshot_hash"]: + raise AssertionError(f"{case['name']} 的候选哈希与共享向量不一致") + key = _idempotency_key(case["task_id"], request) + if key != case["idempotency_key"]: + raise AssertionError(f"{case['name']} 的幂等键与共享向量不一致") + return request, key + + +def _receipt_for_case(case: dict, request: dict) -> SpecResolutionReceipt: + expected = case["expected"] + match = None + if expected["candidate_id"] is not None: + candidate = next( + item + for item in request["candidates"] + if item["candidate_id"] == expected["candidate_id"] + ) + match = SpecResolutionMatch( + candidate["candidate_id"], + candidate["raw_text"], + dict(candidate["options"]), + ) + return SpecResolutionReceipt( + 1, + f"psr-vector-{case['name']}", + expected["outcome"], + expected["source"], + request["candidate_snapshot_hash"], + match, + expected["confidence_bps"], + f"共享向量 {case['name']}", + "2026-08-17T08:00:01Z", + ) + + +def _receipt_payload(receipt: SpecResolutionReceipt) -> dict: + return { + "schema_version": receipt.schema_version, + "resolution_id": receipt.resolution_id, + "outcome": receipt.outcome, + "source": receipt.source, + "candidate_snapshot_hash": receipt.candidate_snapshot_hash, + "match": ( + { + "candidate_id": receipt.match.candidate_id, + "raw_text": receipt.match.raw_text, + "options": dict(receipt.match.options), + } + if receipt.match is not None + else None + ), + "confidence_bps": receipt.confidence_bps, + "reason": receipt.reason, + "resolved_at": receipt.resolved_at, + } + + +class PurchaseSpecResolutionContractVectorTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.fixture = json.loads(CONTRACT_PATH.read_text(encoding="utf-8")) + if ( + cls.fixture.get("contract") != "purchase-spec-resolution-v1" + or cls.fixture.get("schema_version") != 1 + ): + raise AssertionError("共享规格解析契约向量头无效") + cls.cases = { + case["name"]: case for case in cls.fixture["cases"] + } + + def test_fixture_contains_only_synthetic_business_identifiers(self): + serialized = json.dumps(self.fixture, ensure_ascii=False).lower() + for forbidden_key in ( + '"order_no"', + '"phone"', + '"address"', + '"cookie"', + '"token"', + '"password"', + '"api_key"', + '"device_serial"', + ): + self.assertNotIn(forbidden_key, serialized) + defaults = self.fixture["defaults"] + self.assertTrue(defaults["client_id"].startswith("CLIENT-CONTRACT")) + self.assertTrue(defaults["pdd_goods_id"].startswith("PDD-CONTRACT")) + for case in self.fixture["cases"]: + self.assertTrue(case["task_id"].startswith("cg-contract-")) + + def _task(self, case: dict, task_type=TaskType.PURCHASE) -> AdminTask: + defaults = self.fixture["defaults"] + return AdminTask( + task_id=case["task_id"], + task_type=task_type, + version=defaults["task_version"], + priority=1, + payload={ + "goods_id": defaults["pdd_goods_id"], + "options": { + "color": defaults["selected_color"], + "size": case["target_size"], + }, + }, + ) + + @staticmethod + def _claim(gateway: MockAdminGateway): + return gateway.claim_next( + ClientInfo("CLIENT-CONTRACT"), + ClaimCapabilities( + device=AndroidDeviceInfo("TEST-DEVICE"), + supported_types=(TaskType.COLLECT, TaskType.PURCHASE), + ), + ) + + def test_valid_vectors_are_identical_for_http_and_mock(self): + defaults = self.fixture["defaults"] + for case in self.fixture["cases"]: + with self.subTest(case=case["name"]): + request, key = _build_request(defaults, case) + expected = _receipt_for_case(case, request) + + mock = MockAdminGateway() + mock.enqueue_task(self._task(case), defaults["client_id"]) + self._claim(mock) + mock.set_next_spec_resolution(expected) + first = mock.resolve_purchase_spec( + case["task_id"], key, request + ) + second = mock.resolve_purchase_spec( + case["task_id"], key, request + ) + self.assertEqual(first, expected) + self.assertEqual(second, expected) + self.assertEqual(mock.spec_resolution_count, 1) + + opener = _RecordingOpener( + _FakeResponse(200, _receipt_payload(expected)) + ) + http = HttpAdminGateway( + opener=opener, + client_id=defaults["client_id"], + ) + parsed = http.resolve_purchase_spec( + case["task_id"], key, request + ) + self.assertEqual(parsed, expected) + self.assertEqual( + json.loads(opener.request.data.decode("utf-8")), request + ) + headers = { + name.lower(): value + for name, value in opener.request.header_items() + } + self.assertEqual(headers["idempotency-key"], key) + + def test_error_vectors_are_identical_for_http_and_mock(self): + defaults = self.fixture["defaults"] + for error_case in self.fixture["error_cases"]: + with self.subTest(case=error_case["name"]): + case = self.cases[error_case["base_case"]] + request, key = _build_request(defaults, case) + task_id = case["task_id"] + task_id, key = self._mutate( + error_case["mutation"], task_id, request, key + ) + + mock = MockAdminGateway() + if error_case["mutation"] != "task_not_found": + task_type = ( + TaskType.COLLECT + if error_case["mutation"] == "task_not_purchase" + else TaskType.PURCHASE + ) + mock.enqueue_task( + self._task(case, task_type), defaults["client_id"] + ) + if error_case["mutation"] != "task_not_claimed": + self._claim(mock) + if error_case["mutation"] == "idempotency_conflict": + original, original_key = _build_request(defaults, case) + mock.set_next_spec_resolution( + _receipt_for_case(case, original) + ) + mock.resolve_purchase_spec( + case["task_id"], original_key, original + ) + with self.assertRaises(AdminGatewayError) as mock_error: + mock.resolve_purchase_spec(task_id, key, request) + self.assertEqual( + mock_error.exception.code, + error_case["expected_error_code"], + ) + self.assertFalse(mock_error.exception.retryable) + + error_payload = { + "error": { + "code": error_case["expected_error_code"], + "message": "共享契约错误向量", + "retryable": False, + "request_id": "request-vector", + "details": {}, + } + } + http_error = HTTPError( + "http://127.0.0.1/spec-resolution", + error_case["expected_http_status"], + "contract error", + hdrs=None, + fp=io.BytesIO( + json.dumps(error_payload).encode("utf-8") + ), + ) + http = HttpAdminGateway( + opener=_RecordingOpener(http_error), + client_id=defaults["client_id"], + ) + with self.assertRaises(AdminGatewayError) as http_raised: + http.resolve_purchase_spec(task_id, key, request) + self.assertEqual( + http_raised.exception.code, + error_case["expected_error_code"], + ) + self.assertFalse(http_raised.exception.retryable) + + @staticmethod + def _mutate( + mutation: str, task_id: str, request: dict, key: str + ) -> tuple[str, str]: + if mutation == "schema_version_2": + request["schema_version"] = 2 + elif mutation == "candidate_id_gap": + request["candidates"][0]["candidate_id"] = "c2" + request["candidate_snapshot_hash"] = _snapshot_hash(request) + key = _idempotency_key(task_id, request) + elif mutation == "snapshot_hash_mismatch": + request["candidate_snapshot_hash"] = "0" * 64 + key = _idempotency_key(task_id, request) + elif mutation == "selected_color_not_claimed": + request["selected_color"] = "测试白" + for candidate in request["candidates"]: + candidate["options"]["color"] = "测试白" + request["candidate_snapshot_hash"] = _snapshot_hash(request) + key = _idempotency_key(task_id, request) + elif mutation == "target_size_not_claimed": + request["target_size"] = "70公斤" + elif mutation == "observed_at_without_timezone": + request["observed_at"] = "2026-08-17T08:00:00" + elif mutation == "attempt_id_too_long": + request["attempt_id"] = "a" * 192 + key = _idempotency_key(task_id, request) + elif mutation == "body_too_large": + request["future_padding"] = "x" * (64 * 1024) + elif mutation == "task_not_found": + task_id = "cg-contract-missing" + key = _idempotency_key(task_id, request) + elif mutation in {"task_not_purchase", "task_not_claimed"}: + pass + elif mutation == "task_version_conflict": + request["task_version"] = 4 + elif mutation == "pdd_goods_mismatch": + request["pdd_goods_id"] = "PDD-CONTRACT-OTHER" + request["candidate_snapshot_hash"] = _snapshot_hash(request) + key = _idempotency_key(task_id, request) + elif mutation == "idempotency_conflict": + request["observed_at"] = "2026-08-17T08:00:01Z" + else: + raise AssertionError(f"未知契约变体 {mutation}") + return task_id, key + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/admin/06-quality-security.md b/docs/admin/06-quality-security.md index 12abb23..b7b05f7 100644 --- a/docs/admin/06-quality-security.md +++ b/docs/admin/06-quality-security.md @@ -281,3 +281,33 @@ CMAutoBuyAdmin/ `interrupted`;成功映射不回滚,未完成条目允许重新勾选,且不能因为重试覆盖人工映射。 - 批次状态接口按创建人隔离,管理员除外;响应和日志只含批次号、计数和脱敏原因,不含密钥、 完整模型请求/响应或订单隐私数据。 + +### 11.1 采购运行时规格解析联合验收 + +Admin 和 Client 必须共同读取 +`testdata/contracts/purchase_spec_resolution_v1.json`,不得各自维护一份容易漂移的样例。 +这份样例只使用虚构任务号、Client 编号、商品标题和规格,不得加入真实订单、账号、地址、 +手机号、设备号或凭据。 + +联合验收至少覆盖: + +- 规则唯一命中、规则歧义、AI 白名单命中、低置信度、额外维度、伪造候选和模型超时; +- `snapshot_hash`、`idempotency_key`、任务版本、领取关系和请求大小等 HTTP 错误; +- 同一请求重放返回同一结果,冲突请求不得覆盖原结果或重复调用模型; +- SQLite 服务测试和以 `_test` 结尾的 MySQL 8 测试库均验证迁移、唯一约束和审计持久化; +- Client 的 Mock 与真实 HTTP Gateway 对同一向量产生相同请求和错误分类。 + +从仓库根目录执行核心联合测试: + +```powershell +Set-Location admin +$env:GOTOOLCHAIN='go1.23.0' +go test ./handler/api ./service -run PurchaseSpecResolutionContractVectors -count=1 + +Set-Location ../client +C:/Python310/python.exe -m pytest -q test/test_purchase_spec_resolution_contract_vectors.py +``` + +真实设备验收不是自动测试的替代品。它只能在项目负责人指定测试商品、账号、Client、Android +设备和价格上限后执行,并且最多创建一个未付款订单;到达不可逆阶段后只能核对订单,禁止重试 +下单,整个过程不进入付款页面。 diff --git a/docs/client/06-quality-security.md b/docs/client/06-quality-security.md index fdfa9a8..74aac45 100644 --- a/docs/client/06-quality-security.md +++ b/docs/client/06-quality-security.md @@ -298,6 +298,31 @@ Artifact 写入前应脱敏,数据库只保存引用。保留周期由设置 第 8、9 项开发者**不要自己判断验收通过**。新增依赖时把包名和许可证类型报给项目负责人;真实设备下单验收必须由项目负责人和实际操作的人共同确认,见 §3。 +### 10.1 采购运行时规格解析联合验收 + +Admin 和 Client 共同使用仓库根目录的 +`testdata/contracts/purchase_spec_resolution_v1.json`。样例只允许虚构任务、商品和规格, +不得写入真实订单、账号、地址、手机号、设备号或凭据。 + +Client 联合验收必须确认: + +- Mock Admin 与真实 HTTP Gateway 对同一请求生成相同正文、幂等键、回执和错误分类; +- 规则命中、AI 命中、无法判断、拒绝和超时均按约定保存结果,不静默改用错误规格; +- 请求发出前先保存规格快照,超时后不自动重复请求; +- 收到结果后重新读取商品页,页面变化或候选消失时停止; +- 选中规格后再次核对商品、规格、数量和价格,再写入不可逆标记并且只提交一次; +- 不可逆标记存在时,重启或异常恢复只允许核对订单,不能再次下单。 + +从 `client/` 目录执行共享契约测试: + +```powershell +C:/Python310/python.exe -m pytest -q test/test_purchase_spec_resolution_contract_vectors.py +``` + +真实设备验收必须由项目负责人先指定测试商品、账号、Client、Android 设备和价格上限,并由 +操作人员全程观察。每次验收最多产生一个未付款订单,不进入付款页面;一旦写入不可逆标记, +任何超时、断网、程序退出或页面异常都只核对订单,不重新下单。 + ## 11. 任务完成定义 单元任务只有同时满足以下条件才算完成: diff --git a/testdata/contracts/purchase_spec_resolution_v1.json b/testdata/contracts/purchase_spec_resolution_v1.json new file mode 100644 index 0000000..517b67c --- /dev/null +++ b/testdata/contracts/purchase_spec_resolution_v1.json @@ -0,0 +1,173 @@ +{ + "contract": "purchase-spec-resolution-v1", + "schema_version": 1, + "description": "Admin、Client HTTP 与 Mock 共用的脱敏采购运行时规格解析向量。", + "defaults": { + "client_id": "CLIENT-CONTRACT", + "task_type": "purchase", + "task_version": 3, + "pdd_goods_id": "PDD-CONTRACT-001", + "selected_color": "测试黑", + "attempt_id": "attempt-contract-001", + "observed_at": "2026-08-17T08:00:00Z", + "ai_confidence_threshold_bps": 9000 + }, + "cases": [ + { + "name": "rule_weight_unique", + "task_id": "cg-contract-rule", + "target_size": "60公斤", + "candidates": ["100斤", "120斤"], + "candidate_snapshot_hash": "4d5cd9f3c8edcc894ef7806df7bd445718ba674b134abd55733df293f25ba23c", + "idempotency_key": "spec-resolution-v1:2c84cb3ec6a934384cfe62b8b143d5def0f4cafdc86b77538f8606478bd16ba3", + "mode": "rule", + "model": null, + "expected": { + "outcome": "matched", + "source": "rule", + "candidate_id": "c2", + "confidence_bps": 10000, + "model_calls": 0 + } + }, + { + "name": "rule_overlap_uncertain", + "task_id": "cg-contract-overlap", + "target_size": "50-60公斤", + "candidates": ["90-110斤", "110-130斤"], + "candidate_snapshot_hash": "1f186fc40144039ccbaf8d3fbe2bf462cb9b24af101f0f6420fb4ca55dabab72", + "idempotency_key": "spec-resolution-v1:a812466f92356dccaae11e59346408fcfa5c2bccdbfcd88991647bbce955f67e", + "mode": "rule", + "model": null, + "expected": { + "outcome": "uncertain", + "source": "rule", + "candidate_id": null, + "confidence_bps": null, + "model_calls": 0 + } + }, + { + "name": "ai_whitelist_match", + "task_id": "cg-contract-ai-match", + "target_size": "L码", + "candidates": ["M码", "XL码"], + "candidate_snapshot_hash": "f634515c8a7a0ebd688fd926520405051e12c622c87a4783a05936099ecfb2fa", + "idempotency_key": "spec-resolution-v1:76722c891bf2c154ec7a129f8628404042305235084c2ea11ecc696383545030", + "mode": "ai", + "model": { + "conclusion": "match", + "candidate_id": "c2", + "confidence_bps": 9300, + "reason": "测试模型唯一选择" + }, + "expected": { + "outcome": "matched", + "source": "ai", + "candidate_id": "c2", + "confidence_bps": 9300, + "model_calls": 1 + } + }, + { + "name": "ai_forged_candidate", + "task_id": "cg-contract-ai-forged", + "target_size": "L码", + "candidates": ["M码", "XL码"], + "candidate_snapshot_hash": "f634515c8a7a0ebd688fd926520405051e12c622c87a4783a05936099ecfb2fa", + "idempotency_key": "spec-resolution-v1:ef9c252b3df772cca63bb296492f714a340a4c3e26fe504d97a79445a4c6e1b4", + "mode": "ai", + "model": { + "conclusion": "match", + "candidate_id": "c99", + "confidence_bps": 9500, + "reason": "测试伪造候选" + }, + "expected": { + "outcome": "rejected", + "source": "ai", + "candidate_id": null, + "confidence_bps": 9500, + "model_calls": 1 + } + }, + { + "name": "ai_low_confidence", + "task_id": "cg-contract-ai-low", + "target_size": "L码", + "candidates": ["M码", "XL码"], + "candidate_snapshot_hash": "f634515c8a7a0ebd688fd926520405051e12c622c87a4783a05936099ecfb2fa", + "idempotency_key": "spec-resolution-v1:cd8ef7bb122d5045434bd3f49e7641ac23a3f43be5bc002fed1363276e1c7142", + "mode": "ai", + "model": { + "conclusion": "match", + "candidate_id": "c2", + "confidence_bps": 7000, + "reason": "测试低置信度" + }, + "expected": { + "outcome": "uncertain", + "source": "ai", + "candidate_id": null, + "confidence_bps": 7000, + "model_calls": 1 + } + }, + { + "name": "ai_missing_dimension", + "task_id": "cg-contract-ai-missing", + "target_size": "L码", + "candidates": ["M码", "XL码"], + "candidate_snapshot_hash": "f634515c8a7a0ebd688fd926520405051e12c622c87a4783a05936099ecfb2fa", + "idempotency_key": "spec-resolution-v1:6fdc71c5d80cae48c76e8fb3f152539c0b063731e47e7c49d21ff0cc4acba0f6", + "mode": "ai", + "model": { + "conclusion": "match", + "candidate_id": "c2", + "confidence_bps": 9500, + "reason": "测试缺少维度", + "missing_dimensions": ["尺码"] + }, + "expected": { + "outcome": "uncertain", + "source": "ai", + "candidate_id": null, + "confidence_bps": 9500, + "model_calls": 1 + } + }, + { + "name": "ai_timeout", + "task_id": "cg-contract-ai-timeout", + "target_size": "L码", + "candidates": ["M码", "XL码"], + "candidate_snapshot_hash": "f634515c8a7a0ebd688fd926520405051e12c622c87a4783a05936099ecfb2fa", + "idempotency_key": "spec-resolution-v1:374abeb2ec0a0dec57f2ba9c2976b9598b977de19cc1bfe1af4b23bc768af912", + "mode": "ai", + "model": {"error": "timeout"}, + "expected": { + "outcome": "failed", + "source": "ai", + "candidate_id": null, + "confidence_bps": null, + "model_calls": 1 + } + } + ], + "error_cases": [ + {"name": "invalid_schema", "base_case": "rule_weight_unique", "mutation": "schema_version_2", "expected_http_status": 400, "expected_error_code": "INVALID_SPEC_RESOLUTION_SCHEMA"}, + {"name": "candidate_id_gap", "base_case": "rule_weight_unique", "mutation": "candidate_id_gap", "expected_http_status": 422, "expected_error_code": "INVALID_SPEC_RESOLUTION_REQUEST"}, + {"name": "snapshot_hash_mismatch", "base_case": "rule_weight_unique", "mutation": "snapshot_hash_mismatch", "expected_http_status": 422, "expected_error_code": "SPEC_RESOLUTION_HASH_MISMATCH"}, + {"name": "selected_color_not_claimed", "base_case": "rule_weight_unique", "mutation": "selected_color_not_claimed", "expected_http_status": 422, "expected_error_code": "INVALID_SPEC_RESOLUTION_REQUEST"}, + {"name": "target_size_not_claimed", "base_case": "rule_weight_unique", "mutation": "target_size_not_claimed", "expected_http_status": 422, "expected_error_code": "INVALID_SPEC_RESOLUTION_REQUEST"}, + {"name": "observed_at_without_timezone", "base_case": "rule_weight_unique", "mutation": "observed_at_without_timezone", "expected_http_status": 422, "expected_error_code": "INVALID_SPEC_RESOLUTION_REQUEST"}, + {"name": "attempt_id_too_long", "base_case": "rule_weight_unique", "mutation": "attempt_id_too_long", "expected_http_status": 422, "expected_error_code": "INVALID_SPEC_RESOLUTION_REQUEST"}, + {"name": "body_too_large", "base_case": "rule_weight_unique", "mutation": "body_too_large", "expected_http_status": 400, "expected_error_code": "INVALID_BODY"}, + {"name": "task_not_found", "base_case": "rule_weight_unique", "mutation": "task_not_found", "expected_http_status": 404, "expected_error_code": "TASK_NOT_FOUND"}, + {"name": "task_not_purchase", "base_case": "rule_weight_unique", "mutation": "task_not_purchase", "expected_http_status": 422, "expected_error_code": "TASK_NOT_PURCHASE"}, + {"name": "task_version_conflict", "base_case": "rule_weight_unique", "mutation": "task_version_conflict", "expected_http_status": 409, "expected_error_code": "TASK_VERSION_CONFLICT"}, + {"name": "pdd_goods_mismatch", "base_case": "rule_weight_unique", "mutation": "pdd_goods_mismatch", "expected_http_status": 422, "expected_error_code": "PDD_GOODS_MISMATCH"}, + {"name": "task_not_claimed", "base_case": "rule_weight_unique", "mutation": "task_not_claimed", "expected_http_status": 403, "expected_error_code": "TASK_NOT_CLAIMED_BY_CLIENT"}, + {"name": "idempotency_conflict", "base_case": "rule_weight_unique", "mutation": "idempotency_conflict", "expected_http_status": 409, "expected_error_code": "IDEMPOTENCY_CONFLICT"} + ] +}