test: 增加采购规格跨端联合验收 (#258)
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user