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 "candidate_count_101": candidates := make([]any, 0, 101) for index := 1; index <= 101; index++ { value := "测试尺码" + strconv.Itoa(index) candidates = append(candidates, map[string]any{ "candidate_id": "c" + strconv.Itoa(index), "raw_text": value, "options": map[string]any{"color": "测试黑", "size": value}, }) } request["candidates"] = candidates apiSpecRefreshHashes(*taskID, request, key) 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 }