ccpoad / internal /app /admin_api_test.go
anyalerob's picture
Upload folder using huggingface_hub
2986042 verified
Raw
History Blame Contribute Delete
37.7 kB
package app
import (
"bytes"
"context"
"encoding/csv"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"slices"
"strings"
"testing"
"time"
"ccLoad/internal/model"
"github.com/gin-gonic/gin"
)
// ==================== Admin API 集成测试 ====================
// TestAdminAPI_ExportChannelsCSV 测试CSV导出功能
func TestAdminAPI_ExportChannelsCSV(t *testing.T) {
// 创建测试环境
server := newInMemoryServer(t)
// 先创建测试渠道
ctx := context.Background()
testChannels := []*model.Config{
{
Name: "Test-Export-1",
URL: "https://api1.example.com",
Priority: 10,
ModelEntries: []model.ModelEntry{
{Model: "model-1", RedirectModel: ""},
},
ChannelType: "anthropic",
Enabled: true,
},
{
Name: "Test-Export-2",
URL: "https://api2.example.com",
Priority: 5,
ModelEntries: []model.ModelEntry{
{Model: "model-2", RedirectModel: ""},
},
ChannelType: "gemini",
Enabled: false,
},
}
for _, cfg := range testChannels {
created, err := server.store.CreateConfig(ctx, cfg)
if err != nil {
t.Fatalf("创建测试渠道失败: %v", err)
}
// 创建API Key
apiKey := &model.APIKey{
ChannelID: created.ID,
KeyIndex: 0,
APIKey: "sk-test-key-" + created.Name,
KeyStrategy: model.KeyStrategySequential,
}
if err := server.store.CreateAPIKeysBatch(ctx, []*model.APIKey{apiKey}); err != nil {
t.Fatalf("创建API Key失败: %v", err)
}
}
c, w := newTestContext(t, newRequest(http.MethodGet, "/admin/channels/export", nil))
// 调用handler
server.HandleExportChannelsCSV(c)
// 验证响应
if w.Code != http.StatusOK {
t.Fatalf("期望状态码 200, 实际 %d", w.Code)
}
// 验证Content-Type
contentType := w.Header().Get("Content-Type")
if !strings.Contains(contentType, "text/csv") {
t.Errorf("期望 Content-Type 包含 text/csv, 实际: %s", contentType)
}
// 验证Content-Disposition
disposition := w.Header().Get("Content-Disposition")
if !strings.Contains(disposition, "attachment") || !strings.Contains(disposition, "channels-") {
t.Errorf("期望 Content-Disposition 包含 attachment 和 channels-, 实际: %s", disposition)
}
// 解析CSV内容
csvReader := csv.NewReader(w.Body)
records, err := csvReader.ReadAll()
if err != nil {
t.Fatalf("解析CSV失败: %v", err)
}
if len(records) < 3 { // 至少header + 2行数据
t.Fatalf("期望至少3行记录(含header),实际: %d", len(records))
}
// 验证CSV header(实际格式:带UTF-8 BOM + 包含api_key和key_strategy)
header := records[0]
// 移除BOM前缀(如果存在)
if len(header) > 0 {
header[0] = strings.TrimPrefix(header[0], "\ufeff")
}
expectedHeaders := []string{"id", "name", "api_key", "url", "priority", "rpm_limit", "models", "model_redirects", "channel_type", "protocol_transforms", "protocol_transform_mode", "key_strategy", "enabled", "scheduled_check_enabled", "scheduled_check_model"}
if len(header) != len(expectedHeaders) {
t.Errorf("Header字段数量不匹配: 期望 %d, 实际: %d\nHeader: %v", len(expectedHeaders), len(header), header)
}
for i, expected := range expectedHeaders {
if i >= len(header) || header[i] != expected {
t.Errorf("Header[%d] 期望 %s, 实际: %s", i, expected, header[i])
}
}
// 验证数据行(应该有15个字段)
if len(records[1]) < 15 {
t.Errorf("数据行字段不足,期望至少15个字段,实际: %d", len(records[1]))
}
}
func TestAdminAPI_ImportChannelsCSV(t *testing.T) {
// 创建测试环境
server := newInMemoryServer(t)
// 创建测试CSV文件(注意:列名是api_key而不是api_keys)
csvContent := `name,url,priority,models,model_redirects,channel_type,protocol_transforms,enabled,api_key,key_strategy,scheduled_check_model
Import-Test-1,https://import1.example.com,10,test-model-1,{},anthropic,openai,true,sk-import-key-1,sequential,test-model-1
Import-Test-2,https://import2.example.com,5,"test-model-2,test-model-3","{""old"":""new""}",gemini,"openai,anthropic",false,sk-import-key-2,round_robin,test-model-3
`
// 创建multipart表单
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
// 添加文件字段
part, err := writer.CreateFormFile("file", "test-import.csv")
if err != nil {
t.Fatalf("创建表单文件字段失败: %v", err)
}
if _, err := io.WriteString(part, csvContent); err != nil {
t.Fatalf("写入CSV内容失败: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("关闭writer失败: %v", err)
}
// [INFO] 修复:使用bytes.NewReader创建新的读取器,避免buffer读取位置问题
req := newRequest(http.MethodPost, "/admin/channels/import", bytes.NewReader(body.Bytes()))
req.Header.Set("Content-Type", writer.FormDataContentType())
c, w := newTestContext(t, req)
// 调用handler
server.HandleImportChannelsCSV(c)
// 验证响应
if w.Code != http.StatusOK {
t.Fatalf("期望状态码 200, 实际 %d, 响应: %s", w.Code, w.Body.String())
}
// [INFO] 调试:输出原始响应内容
t.Logf("原始响应内容: %s", w.Body.String())
var summary ChannelImportSummary
mustUnmarshalAPIResponseData(t, w.Body.Bytes(), &summary)
// 验证导入结果
totalImported := summary.Created + summary.Updated
if totalImported != 2 {
t.Errorf("期望导入2条记录,实际: %d (Created: %d, Updated: %d)", totalImported, summary.Created, summary.Updated)
}
// 输出完整的summary信息用于调试
t.Logf("导入Summary: Created=%d, Updated=%d, Skipped=%d, Processed=%d",
summary.Created, summary.Updated, summary.Skipped, summary.Processed)
// 如果有错误,输出错误信息
if len(summary.Errors) > 0 {
t.Logf("导入过程中的错误: %v", summary.Errors)
}
// 验证数据库中的数据(数据库中的实际结果)
ctx := context.Background()
configs, err := server.store.ListConfigs(ctx)
if err != nil {
t.Fatalf("查询渠道列表失败: %v", err)
}
// 查找导入的渠道
var importedConfigs []*model.Config
for _, cfg := range configs {
if strings.HasPrefix(cfg.Name, "Import-Test-") {
importedConfigs = append(importedConfigs, cfg)
}
}
if len(importedConfigs) != 2 {
t.Errorf("数据库中应有2个导入的渠道,实际: %d", len(importedConfigs))
}
// 验证API Keys是否正确导入
for _, cfg := range importedConfigs {
keys, err := server.store.GetAPIKeys(ctx, cfg.ID)
if err != nil {
t.Errorf("查询API Keys失败 (渠道 %s): %v", cfg.Name, err)
continue
}
if len(keys) != 1 {
t.Errorf("渠道 %s 应有1个API Key,实际: %d", cfg.Name, len(keys))
}
if cfg.Name == "Import-Test-1" && cfg.ScheduledCheckModel != "test-model-1" {
t.Errorf("渠道 %s scheduled_check_model = %q", cfg.Name, cfg.ScheduledCheckModel)
}
if cfg.Name == "Import-Test-2" && cfg.ScheduledCheckModel != "test-model-3" {
t.Errorf("渠道 %s scheduled_check_model = %q", cfg.Name, cfg.ScheduledCheckModel)
}
if cfg.Name == "Import-Test-1" && (len(cfg.ProtocolTransforms) != 1 || cfg.ProtocolTransforms[0] != "openai") {
t.Errorf("渠道 %s protocol_transforms = %#v", cfg.Name, cfg.ProtocolTransforms)
}
if cfg.Name == "Import-Test-2" && (len(cfg.ProtocolTransforms) != 2 || cfg.ProtocolTransforms[0] != "anthropic" || cfg.ProtocolTransforms[1] != "openai") {
t.Errorf("渠道 %s protocol_transforms = %#v", cfg.Name, cfg.ProtocolTransforms)
}
if cfg.GetProtocolTransformMode() != model.ProtocolTransformModeUpstream {
t.Errorf("渠道 %s protocol_transform_mode = %q", cfg.Name, cfg.GetProtocolTransformMode())
}
}
}
func TestAdminAPI_ImportChannelsCSV_UsesExplicitIDForRename(t *testing.T) {
server := newInMemoryServer(t)
ctx := context.Background()
created, err := server.store.CreateConfig(ctx, &model.Config{
Name: "Import-Rename-Source",
URL: "https://old-id.example.com",
Priority: 10,
ModelEntries: []model.ModelEntry{{Model: "old-model", RedirectModel: ""}},
ChannelType: "openai",
Enabled: true,
})
if err != nil {
t.Fatalf("创建现有渠道失败: %v", err)
}
if err := server.store.CreateAPIKeysBatch(ctx, []*model.APIKey{{
ChannelID: created.ID,
KeyIndex: 0,
APIKey: "sk-old-id-key",
KeyStrategy: model.KeyStrategySequential,
}}); err != nil {
t.Fatalf("创建现有 key 失败: %v", err)
}
csvContent := fmt.Sprintf(`id,name,url,priority,models,model_redirects,channel_type,enabled,api_key,key_strategy
%d,Import-Rename-Target,https://new-id.example.com,20,new-model,{},openai,true,sk-new-id-key,sequential
,Import-Rename-Brand-New,https://brand-new.example.com,5,brand-model,{},anthropic,true,sk-brand-new,sequential
`, created.ID)
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", "explicit-id-import.csv")
if err != nil {
t.Fatalf("创建表单文件字段失败: %v", err)
}
if _, err := io.WriteString(part, csvContent); err != nil {
t.Fatalf("写入CSV内容失败: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("关闭writer失败: %v", err)
}
req := newRequest(http.MethodPost, "/admin/channels/import", bytes.NewReader(body.Bytes()))
req.Header.Set("Content-Type", writer.FormDataContentType())
c, w := newTestContext(t, req)
server.HandleImportChannelsCSV(c)
if w.Code != http.StatusOK {
t.Fatalf("期望状态码 200, 实际 %d, 响应: %s", w.Code, w.Body.String())
}
var summary ChannelImportSummary
mustUnmarshalAPIResponseData(t, w.Body.Bytes(), &summary)
if summary.Updated != 1 || summary.Created != 1 {
t.Fatalf("期望更新1条并创建1条,实际 summary=%+v", summary)
}
updated, err := server.store.GetConfig(ctx, created.ID)
if err != nil {
t.Fatalf("查询按ID更新后的渠道失败: %v", err)
}
if updated.Name != "Import-Rename-Target" {
t.Fatalf("期望按ID更新名称,实际为 %q", updated.Name)
}
if updated.URL != "https://new-id.example.com" {
t.Fatalf("期望按ID更新 URL,实际为 %q", updated.URL)
}
if len(updated.ModelEntries) != 1 || updated.ModelEntries[0].Model != "new-model" {
t.Fatalf("期望按ID更新模型,实际为 %+v", updated.ModelEntries)
}
keys, err := server.store.GetAPIKeys(ctx, created.ID)
if err != nil {
t.Fatalf("查询按ID更新后的 key 失败: %v", err)
}
if len(keys) != 1 || keys[0].APIKey != "sk-new-id-key" {
t.Fatalf("期望按ID更新 key,实际为 %+v", keys)
}
configs, err := server.store.ListConfigs(ctx)
if err != nil {
t.Fatalf("查询渠道列表失败: %v", err)
}
newCount := 0
for _, cfg := range configs {
if cfg.Name == "Import-Rename-Brand-New" {
newCount++
if cfg.ID == created.ID {
t.Fatalf("新渠道不应复用旧 ID")
}
}
if cfg.Name == "Import-Rename-Source" {
t.Fatalf("旧名称渠道不应保留为额外记录")
}
}
if newCount != 1 {
t.Fatalf("期望新增渠道1条,实际: %d", newCount)
}
}
func TestAdminAPI_ImportChannelsCSV_MissingScheduledCheckColumnPreservesExistingValue(t *testing.T) {
server := newInMemoryServer(t)
ctx := context.Background()
created, err := server.store.CreateConfig(ctx, &model.Config{
Name: "Import-Preserve-Scheduled",
URL: "https://old.example.com",
Priority: 10,
ModelEntries: []model.ModelEntry{{Model: "old-model", RedirectModel: ""}},
ChannelType: "openai",
Enabled: true,
ScheduledCheckEnabled: true,
ScheduledCheckModel: "old-model",
})
if err != nil {
t.Fatalf("创建现有渠道失败: %v", err)
}
if err := server.store.CreateAPIKeysBatch(ctx, []*model.APIKey{{
ChannelID: created.ID,
KeyIndex: 0,
APIKey: "sk-old-key",
KeyStrategy: model.KeyStrategySequential,
}}); err != nil {
t.Fatalf("创建现有 key 失败: %v", err)
}
csvContent := `name,url,priority,models,model_redirects,channel_type,enabled,api_key,key_strategy
Import-Preserve-Scheduled,https://new.example.com,20,"old-model,new-model",{},openai,true,sk-new-key,sequential
`
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", "legacy-import.csv")
if err != nil {
t.Fatalf("创建表单文件字段失败: %v", err)
}
if _, err := io.WriteString(part, csvContent); err != nil {
t.Fatalf("写入CSV内容失败: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("关闭writer失败: %v", err)
}
req := newRequest(http.MethodPost, "/admin/channels/import", bytes.NewReader(body.Bytes()))
req.Header.Set("Content-Type", writer.FormDataContentType())
c, w := newTestContext(t, req)
server.HandleImportChannelsCSV(c)
if w.Code != http.StatusOK {
t.Fatalf("期望状态码 200, 实际 %d, 响应: %s", w.Code, w.Body.String())
}
var summary ChannelImportSummary
mustUnmarshalAPIResponseData(t, w.Body.Bytes(), &summary)
if summary.Updated != 1 {
t.Fatalf("期望更新1条记录,实际 summary=%+v", summary)
}
updated, err := server.store.GetConfig(ctx, created.ID)
if err != nil {
t.Fatalf("查询更新后的渠道失败: %v", err)
}
if !updated.ScheduledCheckEnabled {
t.Fatalf("缺少 scheduled_check_enabled 列时应保留旧值 true")
}
if updated.ScheduledCheckModel != "old-model" {
t.Fatalf("缺少 scheduled_check_model 列时应保留旧值 old-model,实际为 %q", updated.ScheduledCheckModel)
}
if updated.URL != "https://new.example.com" {
t.Fatalf("期望 URL 已更新,实际为 %s", updated.URL)
}
if len(updated.ModelEntries) != 2 || updated.ModelEntries[0].Model != "old-model" || updated.ModelEntries[1].Model != "new-model" {
t.Fatalf("期望模型已更新,实际为 %+v", updated.ModelEntries)
}
keys, err := server.store.GetAPIKeys(ctx, created.ID)
if err != nil {
t.Fatalf("查询更新后的 key 失败: %v", err)
}
if len(keys) != 1 || keys[0].APIKey != "sk-new-key" {
t.Fatalf("期望 key 已更新,实际为 %+v", keys)
}
}
func TestAdminAPI_ImportChannelsCSV_MissingScheduledCheckColumnClearsInvalidLegacyValue(t *testing.T) {
server := newInMemoryServer(t)
ctx := context.Background()
created, err := server.store.CreateConfig(ctx, &model.Config{
Name: "Import-Clear-Scheduled",
URL: "https://old.example.com",
Priority: 10,
ModelEntries: []model.ModelEntry{{Model: "old-model", RedirectModel: ""}},
ChannelType: "openai",
Enabled: true,
ScheduledCheckEnabled: true,
ScheduledCheckModel: "old-model",
})
if err != nil {
t.Fatalf("创建现有渠道失败: %v", err)
}
if err := server.store.CreateAPIKeysBatch(ctx, []*model.APIKey{{
ChannelID: created.ID,
KeyIndex: 0,
APIKey: "sk-old-key",
KeyStrategy: model.KeyStrategySequential,
}}); err != nil {
t.Fatalf("创建现有 key 失败: %v", err)
}
csvContent := `name,url,priority,models,model_redirects,channel_type,enabled,api_key,key_strategy
Import-Clear-Scheduled,https://new.example.com,20,new-model,{},openai,true,sk-new-key,sequential
`
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", "legacy-import-clear.csv")
if err != nil {
t.Fatalf("创建表单文件字段失败: %v", err)
}
if _, err := io.WriteString(part, csvContent); err != nil {
t.Fatalf("写入CSV内容失败: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("关闭writer失败: %v", err)
}
req := newRequest(http.MethodPost, "/admin/channels/import", bytes.NewReader(body.Bytes()))
req.Header.Set("Content-Type", writer.FormDataContentType())
c, w := newTestContext(t, req)
server.HandleImportChannelsCSV(c)
if w.Code != http.StatusOK {
t.Fatalf("期望状态码 200, 实际 %d, 响应: %s", w.Code, w.Body.String())
}
var summary ChannelImportSummary
mustUnmarshalAPIResponseData(t, w.Body.Bytes(), &summary)
if summary.Updated != 1 {
t.Fatalf("期望更新1条记录,实际 summary=%+v", summary)
}
updated, err := server.store.GetConfig(ctx, created.ID)
if err != nil {
t.Fatalf("查询更新后的渠道失败: %v", err)
}
if updated.ScheduledCheckModel != "" {
t.Fatalf("缺少 scheduled_check_model 列且旧值失效时应清空,实际为 %q", updated.ScheduledCheckModel)
}
}
func TestAdminAPI_ImportChannelsCSV_InvalidURLRejected(t *testing.T) {
server := newInMemoryServer(t)
csvContent := `name,url,priority,models,model_redirects,channel_type,enabled,api_key,key_strategy
Bad-URL,https://bad.example.com/v1,10,test-model,{},anthropic,true,sk-import-key-1,sequential
Good-URL,https://good.example.com,10,test-model,{},anthropic,true,sk-import-key-2,sequential
`
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", "test-import.csv")
if err != nil {
t.Fatalf("创建表单文件字段失败: %v", err)
}
if _, err := io.WriteString(part, csvContent); err != nil {
t.Fatalf("写入CSV内容失败: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("关闭writer失败: %v", err)
}
req := newRequest(http.MethodPost, "/admin/channels/import", bytes.NewReader(body.Bytes()))
req.Header.Set("Content-Type", writer.FormDataContentType())
c, w := newTestContext(t, req)
server.HandleImportChannelsCSV(c)
if w.Code != http.StatusOK {
t.Fatalf("期望状态码 200, 实际 %d, 响应: %s", w.Code, w.Body.String())
}
var summary ChannelImportSummary
mustUnmarshalAPIResponseData(t, w.Body.Bytes(), &summary)
imported := summary.Created + summary.Updated
if imported != 1 {
t.Fatalf("期望导入1条记录,实际: %d (Created: %d, Updated: %d, Skipped: %d, Errors: %v)",
imported, summary.Created, summary.Updated, summary.Skipped, summary.Errors)
}
if summary.Skipped != 1 {
t.Fatalf("期望Skipped=1,实际: %d (Errors: %v)", summary.Skipped, summary.Errors)
}
if len(summary.Errors) == 0 {
t.Fatalf("期望有错误信息,但为空")
}
ctx := context.Background()
configs, err := server.store.ListConfigs(ctx)
if err != nil {
t.Fatalf("查询渠道列表失败: %v", err)
}
var hasBad, hasGood bool
for _, cfg := range configs {
switch cfg.Name {
case "Bad-URL":
hasBad = true
case "Good-URL":
hasGood = true
}
}
if hasBad {
t.Fatalf("Bad-URL 不应被导入")
}
if !hasGood {
t.Fatalf("Good-URL 应被导入")
}
}
func TestAdminAPI_ImportChannelsCSV_InvalidScheduledCheckModelRejected(t *testing.T) {
server := newInMemoryServer(t)
csvContent := `name,url,priority,models,model_redirects,channel_type,enabled,api_key,key_strategy,scheduled_check_model
Bad-Scheduled-Model,https://bad.example.com,10,test-model,{},anthropic,true,sk-import-key-1,sequential,missing-model
`
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", "test-import.csv")
if err != nil {
t.Fatalf("创建表单文件字段失败: %v", err)
}
if _, err := io.WriteString(part, csvContent); err != nil {
t.Fatalf("写入CSV内容失败: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("关闭writer失败: %v", err)
}
req := newRequest(http.MethodPost, "/admin/channels/import", bytes.NewReader(body.Bytes()))
req.Header.Set("Content-Type", writer.FormDataContentType())
c, w := newTestContext(t, req)
server.HandleImportChannelsCSV(c)
if w.Code != http.StatusOK {
t.Fatalf("期望状态码 200, 实际 %d, 响应: %s", w.Code, w.Body.String())
}
var summary ChannelImportSummary
mustUnmarshalAPIResponseData(t, w.Body.Bytes(), &summary)
if summary.Skipped != 1 || len(summary.Errors) == 0 {
t.Fatalf("期望该行被跳过并返回错误,实际 summary=%+v", summary)
}
if !strings.Contains(summary.Errors[0], "scheduled_check_model") {
t.Fatalf("期望错误包含 scheduled_check_model,实际: %v", summary.Errors)
}
}
func TestAdminAPI_ImportChannelsCSV_InvalidProtocolTransformsRejected(t *testing.T) {
server := newInMemoryServer(t)
csvContent := `name,url,priority,models,model_redirects,channel_type,protocol_transforms,enabled,api_key,key_strategy
Bad-Transforms,https://bad.example.com,10,test-model,{},anthropic,"openai,gemini,openai",true,sk-import-key-1,sequential
Good-Transforms,https://good.example.com,10,test-model,{},anthropic,openai,true,sk-import-key-2,sequential
`
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", "test-import.csv")
if err != nil {
t.Fatalf("创建表单文件字段失败: %v", err)
}
if _, err := io.WriteString(part, csvContent); err != nil {
t.Fatalf("写入CSV内容失败: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("关闭writer失败: %v", err)
}
req := newRequest(http.MethodPost, "/admin/channels/import", bytes.NewReader(body.Bytes()))
req.Header.Set("Content-Type", writer.FormDataContentType())
c, w := newTestContext(t, req)
server.HandleImportChannelsCSV(c)
if w.Code != http.StatusOK {
t.Fatalf("期望状态码 200, 实际 %d, 响应: %s", w.Code, w.Body.String())
}
var summary ChannelImportSummary
mustUnmarshalAPIResponseData(t, w.Body.Bytes(), &summary)
if summary.Skipped != 1 || len(summary.Errors) == 0 {
t.Fatalf("期望非法 protocol_transforms 行被跳过并返回错误,实际 summary=%+v", summary)
}
if !strings.Contains(summary.Errors[0], "protocol_transforms") {
t.Fatalf("期望错误包含 protocol_transforms,实际: %v", summary.Errors)
}
ctx := context.Background()
configs, err := server.store.ListConfigs(ctx)
if err != nil {
t.Fatalf("查询渠道列表失败: %v", err)
}
var hasBad, hasGood bool
for _, cfg := range configs {
switch cfg.Name {
case "Bad-Transforms":
hasBad = true
case "Good-Transforms":
hasGood = true
}
}
if hasBad {
t.Fatalf("Bad-Transforms 不应被导入")
}
if !hasGood {
t.Fatalf("Good-Transforms 应被导入")
}
}
func TestAdminAPI_ImportChannelsCSV_PrunesURLSelectorStateForUpdatedChannel(t *testing.T) {
server := newInMemoryServer(t)
ctx := context.Background()
targetCfg, err := server.store.CreateConfig(ctx, &model.Config{
Name: "Import-Prune-Target",
URL: "https://old-import.example.com\nhttps://keep-import.example.com",
Priority: 10,
ModelEntries: []model.ModelEntry{{Model: "m1", RedirectModel: ""}},
ChannelType: "anthropic",
Enabled: true,
})
if err != nil {
t.Fatalf("创建目标渠道失败: %v", err)
}
if err := server.store.CreateAPIKeysBatch(ctx, []*model.APIKey{{
ChannelID: targetCfg.ID,
KeyIndex: 0,
APIKey: "sk-target-import",
KeyStrategy: model.KeyStrategySequential,
}}); err != nil {
t.Fatalf("创建目标渠道 key 失败: %v", err)
}
otherCfg, err := server.store.CreateConfig(ctx, &model.Config{
Name: "Import-Prune-Other",
URL: "https://other-import.example.com",
Priority: 10,
ModelEntries: []model.ModelEntry{{Model: "m1", RedirectModel: ""}},
ChannelType: "anthropic",
Enabled: true,
})
if err != nil {
t.Fatalf("创建其他渠道失败: %v", err)
}
server.urlSelector.RecordLatency(targetCfg.ID, "https://old-import.example.com", 10*time.Millisecond)
server.urlSelector.RecordLatency(targetCfg.ID, "https://keep-import.example.com", 20*time.Millisecond)
server.urlSelector.CooldownURL(targetCfg.ID, "https://old-import.example.com")
server.urlSelector.RecordLatency(otherCfg.ID, "https://other-import.example.com", 30*time.Millisecond)
csvContent := `name,url,priority,models,model_redirects,channel_type,enabled,api_key,key_strategy
Import-Prune-Target,https://keep-import.example.com,10,m1,{},anthropic,true,sk-target-import,sequential
`
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", "import-prune.csv")
if err != nil {
t.Fatalf("创建表单文件字段失败: %v", err)
}
if _, err := io.WriteString(part, csvContent); err != nil {
t.Fatalf("写入CSV内容失败: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("关闭writer失败: %v", err)
}
req := newRequest(http.MethodPost, "/admin/channels/import", bytes.NewReader(body.Bytes()))
req.Header.Set("Content-Type", writer.FormDataContentType())
c, w := newTestContext(t, req)
server.HandleImportChannelsCSV(c)
if w.Code != http.StatusOK {
t.Fatalf("期望状态码 200, 实际 %d, 响应: %s", w.Code, w.Body.String())
}
var summary ChannelImportSummary
mustUnmarshalAPIResponseData(t, w.Body.Bytes(), &summary)
if summary.Updated < 1 {
t.Fatalf("期望至少更新1条记录,实际 summary=%+v", summary)
}
if _, ok := server.urlSelector.latencies[urlKey{channelID: targetCfg.ID, url: "https://old-import.example.com"}]; ok {
t.Fatalf("期望导入更新后旧URL latency状态被清理")
}
if _, ok := server.urlSelector.cooldowns[urlKey{channelID: targetCfg.ID, url: "https://old-import.example.com"}]; ok {
t.Fatalf("期望导入更新后旧URL cooldown状态被清理")
}
if _, ok := server.urlSelector.latencies[urlKey{channelID: targetCfg.ID, url: "https://keep-import.example.com"}]; !ok {
t.Fatalf("期望保留更新后URL的状态")
}
if _, ok := server.urlSelector.latencies[urlKey{channelID: otherCfg.ID, url: "https://other-import.example.com"}]; !ok {
t.Fatalf("期望其他渠道状态不受影响")
}
}
func TestAdminAPI_ImportChannelsCSV_CleansOrphanedURLDisabledStateForNameUpdate(t *testing.T) {
server := newInMemoryServer(t)
ctx := context.Background()
targetCfg, err := server.store.CreateConfig(ctx, &model.Config{
Name: "Import-URL-State",
URL: "https://old-import-state.example.com\nhttps://keep-import-state.example.com",
Priority: 10,
ModelEntries: []model.ModelEntry{{Model: "m1", RedirectModel: ""}},
ChannelType: "anthropic",
Enabled: true,
})
if err != nil {
t.Fatalf("创建目标渠道失败: %v", err)
}
if err := server.store.CreateAPIKeysBatch(ctx, []*model.APIKey{{
ChannelID: targetCfg.ID,
KeyIndex: 0,
APIKey: "sk-import-state",
KeyStrategy: model.KeyStrategySequential,
}}); err != nil {
t.Fatalf("创建目标渠道 key 失败: %v", err)
}
otherCfg, err := server.store.CreateConfig(ctx, &model.Config{
Name: "Import-URL-State-Other",
URL: "https://other-import-state.example.com",
Priority: 10,
ModelEntries: []model.ModelEntry{{Model: "m1", RedirectModel: ""}},
ChannelType: "anthropic",
Enabled: true,
})
if err != nil {
t.Fatalf("创建其他渠道失败: %v", err)
}
if err := server.store.SetURLDisabled(ctx, targetCfg.ID, "https://old-import-state.example.com", true); err != nil {
t.Fatalf("禁用旧 URL 失败: %v", err)
}
if err := server.store.SetURLDisabled(ctx, targetCfg.ID, "https://keep-import-state.example.com", true); err != nil {
t.Fatalf("禁用保留 URL 失败: %v", err)
}
if err := server.store.SetURLDisabled(ctx, otherCfg.ID, "https://other-import-state.example.com", true); err != nil {
t.Fatalf("禁用其他渠道 URL 失败: %v", err)
}
csvContent := `name,url,priority,models,model_redirects,channel_type,enabled,api_key,key_strategy
Import-URL-State,https://keep-import-state.example.com,10,m1,{},anthropic,true,sk-import-state,sequential
`
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", "import-url-state.csv")
if err != nil {
t.Fatalf("创建表单文件字段失败: %v", err)
}
if _, err := io.WriteString(part, csvContent); err != nil {
t.Fatalf("写入CSV内容失败: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("关闭writer失败: %v", err)
}
req := newRequest(http.MethodPost, "/admin/channels/import", bytes.NewReader(body.Bytes()))
req.Header.Set("Content-Type", writer.FormDataContentType())
c, w := newTestContext(t, req)
server.HandleImportChannelsCSV(c)
if w.Code != http.StatusOK {
t.Fatalf("期望状态码 200, 实际 %d, 响应: %s", w.Code, w.Body.String())
}
var summary ChannelImportSummary
mustUnmarshalAPIResponseData(t, w.Body.Bytes(), &summary)
if summary.Updated < 1 {
t.Fatalf("期望按名称更新已有渠道,实际 summary=%+v", summary)
}
disabledURLs, err := server.store.LoadDisabledURLs(ctx)
if err != nil {
t.Fatalf("加载禁用 URL 状态失败: %v", err)
}
if slices.Contains(disabledURLs[targetCfg.ID], "https://old-import-state.example.com") {
t.Fatalf("期望 CSV 更新后旧 URL 禁用状态被清理")
}
if !slices.Contains(disabledURLs[targetCfg.ID], "https://keep-import-state.example.com") {
t.Fatalf("期望保留 URL 的禁用状态仍存在")
}
if !slices.Contains(disabledURLs[otherCfg.ID], "https://other-import-state.example.com") {
t.Fatalf("期望其他渠道禁用状态不受影响")
}
}
// TestAdminAPI_ExportImportRoundTrip 测试完整的导出-导入循环
func TestAdminAPI_ExportImportRoundTrip(t *testing.T) {
// 创建测试环境
server := newInMemoryServer(t)
ctx := context.Background()
// 步骤1:创建原始测试数据
originalConfig := &model.Config{
Name: "RoundTrip-Test",
URL: "https://roundtrip.example.com",
Priority: 15,
ModelEntries: []model.ModelEntry{
{Model: "model-a", RedirectModel: ""},
{Model: "model-b", RedirectModel: ""},
{Model: "old-model", RedirectModel: "new-model"},
},
ChannelType: "anthropic",
Enabled: true,
}
created, err := server.store.CreateConfig(ctx, originalConfig)
if err != nil {
t.Fatalf("创建原始渠道失败: %v", err)
}
// 创建API Keys
apiKeys := []*model.APIKey{
{
ChannelID: created.ID,
KeyIndex: 0,
APIKey: "sk-roundtrip-key-1",
KeyStrategy: model.KeyStrategySequential,
},
{
ChannelID: created.ID,
KeyIndex: 1,
APIKey: "sk-roundtrip-key-2",
KeyStrategy: model.KeyStrategySequential,
},
}
if err := server.store.CreateAPIKeysBatch(ctx, apiKeys); err != nil {
t.Fatalf("创建API Keys失败: %v", err)
}
// 步骤2:导出CSV
exportC, exportW := newTestContext(t, newRequest(http.MethodGet, "/admin/channels/export", nil))
server.HandleExportChannelsCSV(exportC)
if exportW.Code != http.StatusOK {
t.Fatalf("导出失败,状态码: %d", exportW.Code)
}
exportedCSV := exportW.Body.Bytes()
// 步骤3:删除原始数据
if err := server.store.DeleteConfig(ctx, created.ID); err != nil {
t.Fatalf("删除原始渠道失败: %v", err)
}
// 步骤4:重新导入CSV
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", "roundtrip.csv")
if err != nil {
t.Fatalf("创建表单文件字段失败: %v", err)
}
if _, err := part.Write(exportedCSV); err != nil {
t.Fatalf("写入CSV内容失败: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("关闭writer失败: %v", err)
}
// [INFO] 修复:使用bytes.NewReader创建新的读取器
importReq := newRequest(http.MethodPost, "/admin/channels/import", bytes.NewReader(body.Bytes()))
importReq.Header.Set("Content-Type", writer.FormDataContentType())
importC, importW := newTestContext(t, importReq)
server.HandleImportChannelsCSV(importC)
if importW.Code != http.StatusOK {
t.Fatalf("导入失败,状态码: %d, 响应: %s", importW.Code, importW.Body.String())
}
// 步骤5:验证数据完整性
configs, err := server.store.ListConfigs(ctx)
if err != nil {
t.Fatalf("查询渠道列表失败: %v", err)
}
var restoredConfig *model.Config
for _, cfg := range configs {
if cfg.Name == "RoundTrip-Test" {
restoredConfig = cfg
break
}
}
if restoredConfig == nil {
t.Fatalf("未找到恢复的渠道 RoundTrip-Test")
}
// 验证字段完整性
if restoredConfig.URL != originalConfig.URL {
t.Errorf("URL不匹配: 期望 %s, 实际 %s", originalConfig.URL, restoredConfig.URL)
}
if restoredConfig.Priority != originalConfig.Priority {
t.Errorf("Priority不匹配: 期望 %d, 实际 %d", originalConfig.Priority, restoredConfig.Priority)
}
if len(restoredConfig.ModelEntries) != len(originalConfig.ModelEntries) {
t.Errorf("ModelEntries数量不匹配: 期望 %d, 实际 %d", len(originalConfig.ModelEntries), len(restoredConfig.ModelEntries))
}
// 验证API Keys
restoredKeys, err := server.store.GetAPIKeys(ctx, restoredConfig.ID)
if err != nil {
t.Fatalf("查询恢复的API Keys失败: %v", err)
}
if len(restoredKeys) != len(apiKeys) {
t.Errorf("API Keys数量不匹配: 期望 %d, 实际 %d", len(apiKeys), len(restoredKeys))
}
}
// ==================== 边界条件测试 ====================
// TestAdminAPI_ImportCSV_InvalidFormat 测试无效CSV格式
func TestAdminAPI_ImportCSV_InvalidFormat(t *testing.T) {
server := newInMemoryServer(t)
// 缺少必要字段的CSV
invalidCSV := `name,url
Test-Invalid,https://invalid.com
`
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", "invalid.csv")
if err != nil {
t.Fatalf("创建表单文件字段失败: %v", err)
}
if _, err := io.WriteString(part, invalidCSV); err != nil {
t.Fatalf("写入CSV内容失败: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("关闭writer失败: %v", err)
}
// [INFO] 修复:使用bytes.NewReader创建新的读取器
req := newRequest(http.MethodPost, "/admin/channels/import", bytes.NewReader(body.Bytes()))
req.Header.Set("Content-Type", writer.FormDataContentType())
c, w := newTestContext(t, req)
server.HandleImportChannelsCSV(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("期望状态码 400, 实际 %d, 响应: %s", w.Code, w.Body.String())
}
resp := mustParseAPIResponse[json.RawMessage](t, w.Body.Bytes())
if resp.Success {
t.Fatalf("期望 success=false, 实际=true, data=%s", string(resp.Data))
}
if !strings.Contains(resp.Error, "缺少必需列") {
t.Fatalf("期望错误包含“缺少必需列”,实际 error=%q", resp.Error)
}
}
// TestAdminAPI_ImportCSV_DuplicateNames 测试重复渠道名称处理
func TestAdminAPI_ImportCSV_DuplicateNames(t *testing.T) {
server := newInMemoryServer(t)
ctx := context.Background()
// 先创建一个渠道
existing := &model.Config{
Name: "Duplicate-Test",
URL: "https://existing.com",
Priority: 10,
ModelEntries: []model.ModelEntry{{Model: "model-1", RedirectModel: ""}},
ChannelType: "anthropic",
Enabled: true,
}
_, err := server.store.CreateConfig(ctx, existing)
if err != nil {
t.Fatalf("创建现有渠道失败: %v", err)
}
// 尝试导入同名渠道 - [INFO] 修复:添加必需的api_key和key_strategy列
duplicateCSV := `name,url,priority,models,model_redirects,channel_type,enabled,api_key,key_strategy
Duplicate-Test,https://duplicate.com,5,model-2,{},gemini,false,sk-duplicate-key,sequential
`
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", "duplicate.csv")
if err != nil {
t.Fatalf("创建表单文件字段失败: %v", err)
}
if _, err := io.WriteString(part, duplicateCSV); err != nil {
t.Fatalf("写入CSV内容失败: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("关闭writer失败: %v", err)
}
// [INFO] 修复:使用bytes.NewReader创建新的读取器
req := newRequest(http.MethodPost, "/admin/channels/import", bytes.NewReader(body.Bytes()))
req.Header.Set("Content-Type", writer.FormDataContentType())
c, w := newTestContext(t, req)
server.HandleImportChannelsCSV(c)
if w.Code != http.StatusOK {
t.Fatalf("期望状态码 200, 实际 %d, 响应: %s", w.Code, w.Body.String())
}
resp := mustParseAPIResponse[ChannelImportSummary](t, w.Body.Bytes())
if !resp.Success {
t.Fatalf("success=false, error=%q", resp.Error)
}
if resp.Data.Created != 0 || resp.Data.Updated != 1 || resp.Data.Skipped != 0 || resp.Data.Processed != 1 {
t.Fatalf("summary=%+v, want created=0 updated=1 skipped=0 processed=1", resp.Data)
}
// 验证数据库中只有一个渠道
configs, _ := server.store.ListConfigs(ctx)
duplicateCount := 0
for _, cfg := range configs {
if cfg.Name == "Duplicate-Test" {
duplicateCount++
}
}
if duplicateCount > 1 {
t.Errorf("数据库中不应有重复的渠道名称,实际数量: %d", duplicateCount)
}
}
// TestAdminAPI_ExportCSV_EmptyDatabase 测试空数据库导出
func TestAdminAPI_ExportCSV_EmptyDatabase(t *testing.T) {
server := newInMemoryServer(t)
c, w := newTestContext(t, newRequest(http.MethodGet, "/admin/channels/export", nil))
server.HandleExportChannelsCSV(c)
if w.Code != http.StatusOK {
t.Fatalf("期望状态码 200, 实际 %d", w.Code)
}
// 解析CSV
csvReader := csv.NewReader(w.Body)
records, err := csvReader.ReadAll()
if err != nil {
t.Fatalf("解析CSV失败: %v", err)
}
// 空数据库应该只有header行
if len(records) != 1 {
t.Errorf("空数据库导出应该只有1行(header),实际: %d", len(records))
}
}
// TestHealthEndpoint 测试健康检查端点
func TestHealthEndpoint(t *testing.T) {
server := newInMemoryServer(t)
r := gin.New()
server.SetupRoutes(r)
// 测试健康检查端点
w := serveHTTP(t, r, newRequest(http.MethodGet, "/health", nil))
if w.Code != http.StatusOK {
t.Fatalf("期望状态码 200,实际: %d, 响应: %s", w.Code, w.Body.String())
}
type healthData struct {
Status string `json:"status"`
}
resp := mustParseAPIResponse[healthData](t, w.Body.Bytes())
if !resp.Success {
t.Fatalf("success=false, error=%q", resp.Error)
}
if resp.Data.Status != "ok" {
t.Fatalf("期望 status='ok',实际: %v", resp.Data.Status)
}
}