266 lines
10 KiB
Go
266 lines
10 KiB
Go
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)
|
|
}
|
|
}
|