diff --git a/admin/handler/web/ai_config_test.go b/admin/handler/web/ai_config_test.go new file mode 100644 index 0000000..19e84ef --- /dev/null +++ b/admin/handler/web/ai_config_test.go @@ -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"}, + } +} diff --git a/admin/repository/ai_config.go b/admin/repository/ai_config.go index 387ce2a..b2ff562 100644 --- a/admin/repository/ai_config.go +++ b/admin/repository/ai_config.go @@ -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) diff --git a/admin/repository/ai_config_test.go b/admin/repository/ai_config_test.go new file mode 100644 index 0000000..1b4f011 --- /dev/null +++ b/admin/repository/ai_config_test.go @@ -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) + } + } +} diff --git a/admin/service/ai_config_test.go b/admin/service/ai_config_test.go index 0f66811..8eb8b2e 100644 --- a/admin/service/ai_config_test.go +++ b/admin/service/ai_config_test.go @@ -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{