197 lines
9.1 KiB
Go
197 lines
9.1 KiB
Go
package aimatching
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"io"
|
||
"net/http"
|
||
"strings"
|
||
"testing"
|
||
|
||
"go-admin/app/goauto/migrations"
|
||
"go-admin/app/goauto/models"
|
||
|
||
"gorm.io/driver/sqlite"
|
||
"gorm.io/gorm"
|
||
"gorm.io/gorm/logger"
|
||
)
|
||
|
||
func matcherTestService(t *testing.T) *Service {
|
||
t.Helper()
|
||
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err = migrations.Migrate(db); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return &Service{DB: db, HTTPClient: &http.Client{Transport: roundTripper(func(request *http.Request) (*http.Response, error) {
|
||
body := `{"data":[{"id":"test-model"}]}`
|
||
if strings.HasSuffix(request.URL.Path, "/chat/completions") {
|
||
body = `{"choices":[{"message":{"content":"{\"color\":\"米白\",\"size\":\"4XL 160-170斤\",\"reason\":\"候选唯一\",\"confidence\":0.91}"}}]}`
|
||
}
|
||
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: request}, nil
|
||
})}}
|
||
}
|
||
|
||
type roundTripper func(*http.Request) (*http.Response, error)
|
||
|
||
func (fn roundTripper) RoundTrip(request *http.Request) (*http.Response, error) { return fn(request) }
|
||
|
||
func TestNormalizeTraditionalWidthAndWeightRange(t *testing.T) {
|
||
if got, want := Normalize(" 4XL 80-85公斤 "), Normalize("4xl 160-170斤"); got != want {
|
||
t.Fatalf("normalization mismatch: %q != %q", got, want)
|
||
}
|
||
if got, want := Normalize("淺藍色"), Normalize("浅蓝色"); got != want {
|
||
t.Fatalf("traditional Chinese was not converted: %q != %q", got, want)
|
||
}
|
||
}
|
||
|
||
func TestDeterministicMatchNeverPicksAmbiguousCandidate(t *testing.T) {
|
||
if _, ok := DeterministicMatch(MatchRequest{TargetColor: "淺藍", Colors: []string{"浅蓝", "淺藍"}}); ok {
|
||
t.Fatal("ambiguous normalized candidates were selected")
|
||
}
|
||
result, ok := DeterministicMatch(MatchRequest{TargetSize: "4XL 80-85公斤", Sizes: []string{"4XL 160-170斤"}})
|
||
if !ok || result.MappedSize != "4XL 160-170斤" || result.Source != SourceExact {
|
||
t.Fatalf("expected exact normalized match, got %+v ok=%v", result, ok)
|
||
}
|
||
}
|
||
|
||
func TestSettingsKeepsInternalKeyAndAIChoiceMustBeExactCandidate(t *testing.T) {
|
||
service := matcherTestService(t)
|
||
view, err := service.SaveSettings(context.Background(), SaveSettingsRequest{Enabled: true, BaseURL: "https://provider.example/v1", Model: "test-model", APIKey: "test-secret", TimeoutSeconds: 8}, 7)
|
||
if err != nil || view.APIKey != "test-secret" || view.BaseURL != "https://provider.example/v1" {
|
||
t.Fatalf("save settings failed: %+v %v", view, err)
|
||
}
|
||
var stored models.AIMatchingSetting
|
||
if err = service.DB.First(&stored, 1).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if stored.APIKey != "test-secret" {
|
||
t.Fatalf("internal API key was not stored for administrator readback")
|
||
}
|
||
if err = service.TestConnection(context.Background()); err != nil {
|
||
t.Fatalf("test connection failed: %v", err)
|
||
}
|
||
result, err := service.Resolve(context.Background(), MatchRequest{TargetColor: "奶白", TargetSize: "4XL 80-85公斤", Colors: []string{"米白"}, Sizes: []string{"4XL 160-170斤"}})
|
||
if err != nil || result.Source != SourceAI || result.MappedColor != "米白" || result.MappedSize != "4XL 160-170斤" {
|
||
t.Fatalf("AI result was not constrained to exact candidates: %+v %v", result, err)
|
||
}
|
||
if result.Decision.Provider != ProviderOpenAICompatible || result.Decision.Model != "test-model" {
|
||
t.Fatalf("decision snapshot lost provider identity: %+v", result.Decision)
|
||
}
|
||
}
|
||
|
||
func TestSettingsReadsSavedKeyForAdministrator(t *testing.T) {
|
||
service := matcherTestService(t)
|
||
if _, err := service.SaveSettings(context.Background(), SaveSettingsRequest{Enabled: true, BaseURL: "https://provider.example/v1", Model: "test-model", APIKey: "test-secret"}, 7); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
view, err := service.Settings(context.Background())
|
||
if err != nil || view.APIKey != "test-secret" {
|
||
t.Fatalf("administrator settings must return saved key: %+v %v", view, err)
|
||
}
|
||
}
|
||
|
||
func TestSaveSettingsAllowsPublicHTTPProvider(t *testing.T) {
|
||
service := matcherTestService(t)
|
||
view, err := service.SaveSettings(context.Background(), SaveSettingsRequest{Enabled: true, BaseURL: "http://192.168.1.10:8000/v1", Model: "local-model", APIKey: "secret"}, 7)
|
||
if err != nil || view.BaseURL != "http://192.168.1.10:8000/v1" {
|
||
t.Fatalf("HTTP provider must remain configurable for a public or private endpoint: %+v %v", view, err)
|
||
}
|
||
}
|
||
|
||
func TestSaveSettingsAllowsTimeoutUpToSixHundredSeconds(t *testing.T) {
|
||
service := matcherTestService(t)
|
||
view, err := service.SaveSettings(context.Background(), SaveSettingsRequest{Enabled: true, BaseURL: "https://provider.example/v1", Model: "test-model", APIKey: "test-secret", TimeoutSeconds: 600}, 7)
|
||
if err != nil || view.TimeoutSeconds != 600 {
|
||
t.Fatalf("600-second timeout must be accepted: %+v %v", view, err)
|
||
}
|
||
_, err = service.SaveSettings(context.Background(), SaveSettingsRequest{Enabled: true, BaseURL: "https://provider.example/v1", Model: "test-model", APIKey: "test-secret", TimeoutSeconds: 601}, 7)
|
||
var settingErr *Error
|
||
if !errors.As(err, &settingErr) || settingErr.Code != CodeInvalidSetting {
|
||
t.Fatalf("601-second timeout must be rejected: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestAutoConfirmThresholdDefaultsPersistsAndValidates(t *testing.T) {
|
||
service := matcherTestService(t)
|
||
view, err := service.Settings(context.Background())
|
||
if err != nil || view.AutoConfirmMinConfidence != 0.9 {
|
||
t.Fatalf("default view=%+v err=%v", view, err)
|
||
}
|
||
threshold := 0.95
|
||
view, err = service.SaveSettings(context.Background(), SaveSettingsRequest{
|
||
Enabled: true, BaseURL: "https://example.com/v1", Model: "test", APIKey: "test-key", TimeoutSeconds: 15,
|
||
AutoConfirmMinConfidence: &threshold,
|
||
}, 1)
|
||
if err != nil || view.AutoConfirmMinConfidence != threshold {
|
||
t.Fatalf("saved view=%+v err=%v", view, err)
|
||
}
|
||
invalid := 1.01
|
||
_, err = service.SaveSettings(context.Background(), SaveSettingsRequest{TimeoutSeconds: 15, AutoConfirmMinConfidence: &invalid}, 1)
|
||
var settingErr *Error
|
||
if !errors.As(err, &settingErr) || settingErr.Code != CodeInvalidSetting {
|
||
t.Fatalf("invalid threshold err=%v", err)
|
||
}
|
||
}
|
||
|
||
func TestResolveSYBSpecUsesOnlyProductSpecAndClosedShopeeCandidates(t *testing.T) {
|
||
service := matcherTestService(t)
|
||
if _, err := service.SaveSettings(context.Background(), SaveSettingsRequest{Enabled: true, BaseURL: "https://provider.example/v1", Model: "test-model", APIKey: "test-secret", TimeoutSeconds: 8}, 7); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var sent map[string]any
|
||
service.HTTPClient = &http.Client{Transport: roundTripper(func(request *http.Request) (*http.Response, error) {
|
||
raw, err := io.ReadAll(request.Body)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := json.Unmarshal(raw, &sent); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
body := `{"choices":[{"message":{"content":"{\"color\":\"黑色\",\"size\":\"XL\",\"reason\":\"原文对应唯一候选\",\"confidence\":0.95}"}}]}`
|
||
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: request}, nil
|
||
})}
|
||
result, err := service.ResolveSYBSpec(context.Background(), SYBSpecParseRequest{ProductSpec: "黑色 XL【备注】", Colors: []string{"黑色", "白色"}, Sizes: []string{"L", "XL"}})
|
||
if err != nil || result.Color != "黑色" || result.Size != "XL" || result.Confidence == nil || *result.Confidence != 0.95 {
|
||
t.Fatalf("result=%+v err=%v", result, err)
|
||
}
|
||
encoded, _ := json.Marshal(sent)
|
||
for _, forbidden := range []string{"orderCode", "address", "rawJson", "price", "test-secret"} {
|
||
if strings.Contains(string(encoded), forbidden) {
|
||
t.Fatalf("provider payload leaked forbidden field %q: %s", forbidden, encoded)
|
||
}
|
||
}
|
||
for _, required := range []string{"productSpec", "shopeeColorCandidates", "shopeeSizeCandidates"} {
|
||
if !strings.Contains(string(encoded), required) {
|
||
t.Fatalf("provider payload missing %q: %s", required, encoded)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestResolveSYBSpecRejectsCandidateOutsideClosedSetAndMissingConfidence(t *testing.T) {
|
||
service := matcherTestService(t)
|
||
if _, err := service.SaveSettings(context.Background(), SaveSettingsRequest{Enabled: true, BaseURL: "https://provider.example/v1", Model: "test-model", APIKey: "test-secret", TimeoutSeconds: 8}, 7); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
responses := []string{
|
||
`{"choices":[{"message":{"content":"{\"color\":\"灰色\",\"size\":\"XL\",\"reason\":\"猜测\",\"confidence\":0.99}"}}]}`,
|
||
`{"choices":[{"message":{"content":"{\"color\":\"黑色\",\"size\":\"XL\",\"reason\":\"候选\"}"}}]}`,
|
||
}
|
||
service.HTTPClient = &http.Client{Transport: roundTripper(func(request *http.Request) (*http.Response, error) {
|
||
body := responses[0]
|
||
responses = responses[1:]
|
||
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: request}, nil
|
||
})}
|
||
request := SYBSpecParseRequest{ProductSpec: "黑 XL", Colors: []string{"黑色"}, Sizes: []string{"XL"}}
|
||
for i := 0; i < 2; i++ {
|
||
_, err := service.ResolveSYBSpec(context.Background(), request)
|
||
var target *Error
|
||
if !errors.As(err, &target) || target.Code != CodeNoMatch {
|
||
t.Fatalf("attempt %d err=%v", i, err)
|
||
}
|
||
}
|
||
}
|