Files
cmautobuy/admin/handler/api/purchase_spec_resolution_contract_test.go
T

344 lines
13 KiB
Go

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
}