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