fix: 修复 AI 服务商配置新增失败 (#222)
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"cmautobuy/admin/internal/testutil"
|
||||
"cmautobuy/admin/model"
|
||||
"cmautobuy/admin/repository"
|
||||
"cmautobuy/admin/service"
|
||||
)
|
||||
|
||||
func TestRedirectAIConfig_区分成功和校验失败(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
tests := []struct {
|
||||
name, message, errorMessage, wantKey, wantValue string
|
||||
}{
|
||||
{name: "成功", message: "配置已保存", wantKey: "msg", wantValue: "配置已保存"},
|
||||
{name: "校验失败", errorMessage: "Base URL 无效", wantKey: "error", wantValue: "Base URL 无效"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
context, _ := gin.CreateTestContext(recorder)
|
||||
context.Request = httptest.NewRequest(http.MethodPost, "/settings/ai/save", nil)
|
||||
redirectAIConfig(context, test.message, test.errorMessage)
|
||||
if context.Writer.Status() != http.StatusSeeOther {
|
||||
t.Fatalf("状态码=%d,期望 303", context.Writer.Status())
|
||||
}
|
||||
location, err := url.Parse(recorder.Header().Get("Location"))
|
||||
if err != nil || location.Path != "/settings/ai" {
|
||||
t.Fatalf("Location=%q err=%v", recorder.Header().Get("Location"), err)
|
||||
}
|
||||
if got := location.Query().Get(test.wantKey); got != test.wantValue {
|
||||
t.Fatalf("%s=%q,期望 %q", test.wantKey, got, test.wantValue)
|
||||
}
|
||||
otherKey := "msg"
|
||||
if test.wantKey == "msg" {
|
||||
otherKey = "error"
|
||||
}
|
||||
if location.Query().Has(otherKey) {
|
||||
t.Fatalf("成功和错误参数不能混用: %s", location.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIConfigSave_校验失败使用Error跳转(t *testing.T) {
|
||||
context, recorder, _ := aiConfigSaveContext(t, url.Values{"confidence_threshold": {"not-a-number"}})
|
||||
context.Set(currentUserKey, &model.User{Role: model.RoleAdmin, Status: model.UserActive})
|
||||
(&Handler{}).AIConfigSave(context)
|
||||
location, err := url.Parse(recorder.Header().Get("Location"))
|
||||
if context.Writer.Status() != http.StatusSeeOther || err != nil || location.Query().Get("error") == "" || location.Query().Has("msg") {
|
||||
t.Fatalf("校验失败跳转错误: code=%d location=%q err=%v", context.Writer.Status(), recorder.Header().Get("Location"), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIConfigSave_数据库异常返回500而非成功跳转(t *testing.T) {
|
||||
db, err := sql.Open("mysql", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
context, recorder, engine := aiConfigSaveContext(t, validAIConfigForm())
|
||||
context.Set(currentUserKey, &model.User{UserID: "USR-1", Role: model.RoleAdmin, Status: model.UserActive})
|
||||
engine.SetHTMLTemplate(template.Must(template.New("root").Parse(
|
||||
`{{define "partials/error"}}{{.Message}}{{end}}`,
|
||||
)))
|
||||
(&Handler{db: db, aiPolicy: service.NewAIEndpointPolicy(nil)}).AIConfigSave(context)
|
||||
if recorder.Code != http.StatusInternalServerError || recorder.Header().Get("Location") != "" {
|
||||
t.Fatalf("数据库异常不能伪装成 303 成功: code=%d location=%q", recorder.Code, recorder.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIConfigSave_真实MySQL新增后列表可见并使用Msg跳转(t *testing.T) {
|
||||
db := testutil.OpenMySQL(t)
|
||||
actor := model.User{UserID: "USR-WEB-AI", Username: "web-ai-admin", PasswordHash: "test-hash",
|
||||
Role: model.RoleAdmin, Status: model.UserActive, PasswordChangedAt: model.NowISO(),
|
||||
CreatedAt: model.NowISO(), UpdatedAt: model.NowISO()}
|
||||
if err := repository.CreateUser(db, actor); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
context, recorder, _ := aiConfigSaveContext(t, validAIConfigForm())
|
||||
context.Set(currentUserKey, &actor)
|
||||
(&Handler{db: db, aiPolicy: service.NewAIEndpointPolicy(nil)}).AIConfigSave(context)
|
||||
location, err := url.Parse(recorder.Header().Get("Location"))
|
||||
if context.Writer.Status() != http.StatusSeeOther || err != nil || location.Query().Get("msg") == "" || location.Query().Has("error") {
|
||||
t.Fatalf("成功跳转错误: code=%d location=%q err=%v", context.Writer.Status(), recorder.Header().Get("Location"), err)
|
||||
}
|
||||
items, err := repository.ListAIProviders(db)
|
||||
if err != nil || len(items) != 1 || items[0].Name != "测试主线路" {
|
||||
t.Fatalf("跳转后应能查询新增配置: items=%+v err=%v", items, err)
|
||||
}
|
||||
}
|
||||
|
||||
func aiConfigSaveContext(t *testing.T, values url.Values) (*gin.Context, *httptest.ResponseRecorder, *gin.Engine) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
context, engine := gin.CreateTestContext(recorder)
|
||||
request := httptest.NewRequest(http.MethodPost, "/settings/ai/save", strings.NewReader(values.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
context.Request = request
|
||||
return context, recorder, engine
|
||||
}
|
||||
|
||||
func validAIConfigForm() url.Values {
|
||||
return url.Values{
|
||||
"name": {"测试主线路"}, "base_url": {"https://api.example.com/v1"}, "model": {"model-1"},
|
||||
"timeout_seconds": {"30"}, "max_concurrency": {"2"}, "confidence_threshold": {"85"},
|
||||
}
|
||||
}
|
||||
@@ -84,7 +84,7 @@ func InsertAIProvider(q Execer, item model.AIProviderConfig) error {
|
||||
_, err := q.Exec(`INSERT INTO ai_provider_configs
|
||||
(provider_id,name,base_url,model,timeout_seconds,max_concurrency,confidence_threshold_bps,
|
||||
enabled,last_test_status,created_by_user_id,updated_by_user_id,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,0,'pending',?,?,?,?,?)`, item.ProviderID, item.Name, item.BaseURL,
|
||||
VALUES(?,?,?,?,?,?,?,0,'pending',?,?,?,?)`, item.ProviderID, item.Name, item.BaseURL,
|
||||
item.Model, item.TimeoutSeconds, item.MaxConcurrency, item.ConfidenceThresholdBPS,
|
||||
item.CreatedByUserID, item.UpdatedByUserID, item.CreatedAt, item.UpdatedAt)
|
||||
return aiProviderWriteError("新增 AI 服务商配置", err)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cmautobuy/admin/model"
|
||||
)
|
||||
|
||||
type aiConfigCaptureExecer struct {
|
||||
query string
|
||||
args []any
|
||||
}
|
||||
|
||||
func (c *aiConfigCaptureExecer) Exec(query string, args ...any) (sql.Result, error) {
|
||||
c.query = query
|
||||
c.args = append([]any(nil), args...)
|
||||
return aiConfigResult(1), nil
|
||||
}
|
||||
|
||||
func (c *aiConfigCaptureExecer) Query(string, ...any) (*sql.Rows, error) {
|
||||
return nil, errors.New("本测试不应执行 Query")
|
||||
}
|
||||
|
||||
func (c *aiConfigCaptureExecer) QueryRow(string, ...any) *sql.Row {
|
||||
panic("本测试不应执行 QueryRow")
|
||||
}
|
||||
|
||||
type aiConfigResult int64
|
||||
|
||||
func (r aiConfigResult) LastInsertId() (int64, error) { return int64(r), nil }
|
||||
func (r aiConfigResult) RowsAffected() (int64, error) { return int64(r), nil }
|
||||
|
||||
func TestInsertAIProvider_占位符与字段参数一一对应(t *testing.T) {
|
||||
item := model.AIProviderConfig{
|
||||
ProviderID: "AIP-1", Name: "主线路", BaseURL: "https://api.example.com/v1",
|
||||
Model: "model-1", TimeoutSeconds: 30, MaxConcurrency: 2, ConfidenceThresholdBPS: 8500,
|
||||
CreatedByUserID: "USR-CREATE", UpdatedByUserID: "USR-UPDATE",
|
||||
CreatedAt: "2026-08-14T00:00:00Z", UpdatedAt: "2026-08-14T00:01:00Z",
|
||||
}
|
||||
capture := &aiConfigCaptureExecer{}
|
||||
if err := InsertAIProvider(capture, item); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := strings.Count(capture.query, "?"), len(capture.args); got != want {
|
||||
t.Fatalf("INSERT 占位符=%d,参数=%d;两者必须相同", got, want)
|
||||
}
|
||||
wantArgs := []any{item.ProviderID, item.Name, item.BaseURL, item.Model, item.TimeoutSeconds,
|
||||
item.MaxConcurrency, item.ConfidenceThresholdBPS, item.CreatedByUserID,
|
||||
item.UpdatedByUserID, item.CreatedAt, item.UpdatedAt}
|
||||
if !reflect.DeepEqual(capture.args, wantArgs) {
|
||||
t.Fatalf("INSERT 参数顺序错误\n实际: %#v\n期望: %#v", capture.args, wantArgs)
|
||||
}
|
||||
for _, fixed := range []string{"0", "'pending'"} {
|
||||
if !strings.Contains(capture.query, fixed) {
|
||||
t.Fatalf("INSERT 缺少固定初始值 %s", fixed)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"cmautobuy/admin/model"
|
||||
"cmautobuy/admin/repository"
|
||||
)
|
||||
|
||||
func TestParseConfidenceThresholdBPS_精确转换(t *testing.T) {
|
||||
@@ -25,6 +26,37 @@ func TestParseConfidenceThresholdBPS_精确转换(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAIProviderConfig_新增后可查询并与审计同事务(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
now := time.Date(2026, 8, 14, 1, 2, 3, 0, time.UTC)
|
||||
actor := model.User{UserID: "USR-AI-CONFIG", Username: "ai-admin", PasswordHash: "test-hash",
|
||||
Role: model.RoleAdmin, Status: model.UserActive, PasswordChangedAt: model.NowISO(),
|
||||
CreatedAt: model.NowISO(), UpdatedAt: model.NowISO()}
|
||||
if err := repository.CreateUser(db, actor); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
input := AIProviderInput{Name: "主线路", BaseURL: "https://api.example.com/v1", Model: "model-1",
|
||||
TimeoutSeconds: 30, MaxConcurrency: 2, ConfidenceThresholdBPS: 8500}
|
||||
providerID, err := SaveAIProviderConfig(db, &actor, input, NewAIEndpointPolicy(nil), now)
|
||||
if err != nil || providerID == "" {
|
||||
t.Fatalf("新增 AI 配置失败: id=%q err=%v", providerID, err)
|
||||
}
|
||||
items, err := repository.ListAIProviders(db)
|
||||
if err != nil || len(items) != 1 || items[0].ProviderID != providerID || items[0].Name != input.Name {
|
||||
t.Fatalf("新增后列表未回显配置: items=%+v err=%v", items, err)
|
||||
}
|
||||
var auditCount int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM ai_provider_audits WHERE provider_id=? AND action='create'`, providerID).Scan(&auditCount); err != nil || auditCount != 1 {
|
||||
t.Fatalf("创建审计数量=%d err=%v", auditCount, err)
|
||||
}
|
||||
if _, err := SaveAIProviderConfig(db, &actor, input, NewAIEndpointPolicy(nil), now.Add(time.Minute)); !IsValidationError(err) {
|
||||
t.Fatalf("重名配置应返回可展示的校验错误: %v", err)
|
||||
}
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM ai_provider_configs`).Scan(&auditCount); err != nil || auditCount != 1 {
|
||||
t.Fatalf("失败请求不得留下半条配置: count=%d err=%v", auditCount, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIEndpointPolicy_阻止凭据和内网地址(t *testing.T) {
|
||||
policy := NewAIEndpointPolicy(nil)
|
||||
for _, raw := range []string{
|
||||
|
||||
Reference in New Issue
Block a user