Files
cmautobuy/admin/service/purchase_spec_resolution_contract_test.go
T

268 lines
11 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"`
ConflictDimensions []string `json:"conflict_dimensions"`
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,
ConflictDimensions: append([]string(nil), testCase.Model.ConflictDimensions...),
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)
}
}