Files
goauto/server/app/goauto/aimatching/service_test.go
T

197 lines
9.1 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}
}