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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user