Files
sub2api-cn-relay-manager/internal/provision/import_service_test.go
2026-05-30 14:42:51 +08:00

1084 lines
42 KiB
Go

package provision
import (
"context"
"errors"
"fmt"
"reflect"
"strings"
"testing"
"sub2api-cn-relay-manager/internal/host/sub2api"
"sub2api-cn-relay-manager/internal/pack"
)
func TestImportServiceImportSubscriptionFlow(t *testing.T) {
host := &fakeHostAdapter{
batchAccounts: []sub2api.AccountRef{{ID: "account_1"}, {ID: "account_2"}},
testResults: map[string]sub2api.ProbeResult{
"account_1": {OK: true, Status: "passed"},
"account_2": {OK: true, Status: "passed"},
},
models: map[string][]sub2api.AccountModel{
"account_1": {{ID: "deepseek-chat"}},
"account_2": {{ID: "deepseek-chat"}},
},
gatewayResult: sub2api.GatewayAccessResult{OK: true, StatusCode: 200, HasExpectedModel: true, Models: []string{"deepseek-chat"}},
}
svc := NewImportService(host)
report, err := svc.Import(context.Background(), ImportRequest{
Provider: sampleProviderManifest(),
Mode: ImportModePartial,
Access: AccessRequest{
Mode: AccessModeSubscription,
ProbeAPIKey: "user-key",
Subscriptions: []SubscriptionTarget{{UserID: "user_1", DurationDays: 30}},
},
Keys: []string{" key-1 ", "key-2", "key-1"},
})
if err != nil {
t.Fatalf("Import() error = %v", err)
}
if report.BatchStatus != BatchStatusSucceeded {
t.Fatalf("BatchStatus = %q, want %q", report.BatchStatus, BatchStatusSucceeded)
}
if report.ProviderStatus != ProviderStatusActive {
t.Fatalf("ProviderStatus = %q, want %q", report.ProviderStatus, ProviderStatusActive)
}
if report.AccessStatus != AccessStatusSubscriptionReady {
t.Fatalf("AccessStatus = %q, want %q", report.AccessStatus, AccessStatusSubscriptionReady)
}
if !reflect.DeepEqual(report.AcceptedKeys, []string{"key-1", "key-2"}) {
t.Fatalf("AcceptedKeys = %#v, want deduped normalized keys", report.AcceptedKeys)
}
if len(host.assignedSubscriptions) != 1 {
t.Fatalf("assigned subscriptions = %d, want 1", len(host.assignedSubscriptions))
}
if host.createGroupReq.SubscriptionType != "subscription" {
t.Fatalf("CreateGroup subscription_type = %q, want %q", host.createGroupReq.SubscriptionType, "subscription")
}
if host.createGroupReq.Platform != "openai" {
t.Fatalf("CreateGroup platform = %q, want %q", host.createGroupReq.Platform, "openai")
}
if host.gatewayProbe.ExpectedModel != "deepseek-chat" {
t.Fatalf("gateway probe model = %q, want %q", host.gatewayProbe.ExpectedModel, "deepseek-chat")
}
if host.testedModels["account_1"] != "deepseek-chat" || host.testedModels["account_2"] != "deepseek-chat" {
t.Fatalf("testedModels = %#v, want deepseek-chat for all created accounts", host.testedModels)
}
}
func TestImportServiceStrictModeFailsWhenAnyAccountProbeFails(t *testing.T) {
host := &fakeHostAdapter{
batchAccounts: []sub2api.AccountRef{{ID: "account_1"}, {ID: "account_2"}},
testResults: map[string]sub2api.ProbeResult{
"account_1": {OK: true, Status: "passed"},
"account_2": {OK: false, Status: "failed", Message: "bad key"},
},
models: map[string][]sub2api.AccountModel{
"account_1": {{ID: "deepseek-chat"}},
"account_2": {{ID: "deepseek-chat"}},
},
}
svc := NewImportService(host)
report, err := svc.Import(context.Background(), ImportRequest{
Provider: sampleProviderManifest(),
Mode: ImportModeStrict,
Access: AccessRequest{Mode: AccessModeSelfService, ProbeAPIKey: "user-key"},
Keys: []string{"key-1", "key-2"},
})
if err == nil {
t.Fatal("Import() error = nil, want strict mode failure")
}
if report.BatchStatus != BatchStatusFailed {
t.Fatalf("BatchStatus = %q, want %q", report.BatchStatus, BatchStatusFailed)
}
if report.ProviderStatus != ProviderStatusFailed {
t.Fatalf("ProviderStatus = %q, want %q", report.ProviderStatus, ProviderStatusFailed)
}
}
func TestImportServiceRejectsUnknownMode(t *testing.T) {
svc := NewImportService(&fakeHostAdapter{})
_, err := svc.Import(context.Background(), ImportRequest{
Provider: sampleProviderManifest(),
Mode: "unknown",
Access: AccessRequest{Mode: AccessModeSelfService},
Keys: []string{"key-1"},
})
if err == nil {
t.Fatal("Import() error = nil, want mode validation error")
}
}
func TestImportServiceMarksBrokenWhenCompletionSmokeFails(t *testing.T) {
host := &fakeHostAdapter{
batchAccounts: []sub2api.AccountRef{{ID: "account_1"}},
testResults: map[string]sub2api.ProbeResult{
"account_1": {OK: true, Status: "passed"},
},
models: map[string][]sub2api.AccountModel{
"account_1": {{ID: "deepseek-chat"}},
},
gatewayResult: sub2api.GatewayAccessResult{OK: true, StatusCode: 200, HasExpectedModel: true, Models: []string{"deepseek-chat"}},
completionResult: sub2api.GatewayCompletionResult{OK: false, StatusCode: 502, ContentType: "application/json", BodyPreview: `{"error":"upstream_error"}`},
}
report, err := NewImportService(host).Import(context.Background(), ImportRequest{
Provider: sampleProviderManifest(),
Mode: ImportModePartial,
Access: AccessRequest{Mode: AccessModeSelfService, ProbeAPIKey: "user-key"},
Keys: []string{"key-1"},
})
if err != nil {
t.Fatalf("Import() error = %v", err)
}
if report.BatchStatus != BatchStatusPartial {
t.Fatalf("BatchStatus = %q, want %q", report.BatchStatus, BatchStatusPartial)
}
if report.ProviderStatus != ProviderStatusDegraded {
t.Fatalf("ProviderStatus = %q, want %q", report.ProviderStatus, ProviderStatusDegraded)
}
if report.AccessStatus != AccessStatusBroken {
t.Fatalf("AccessStatus = %q, want %q", report.AccessStatus, AccessStatusBroken)
}
}
func TestImportServiceTreatsResponsesOnlyProbeFailureAsAdvisoryWhenGatewaySucceeds(t *testing.T) {
host := &fakeHostAdapter{
batchAccounts: []sub2api.AccountRef{{ID: "account_1", Name: "minimax-01"}},
testResults: map[string]sub2api.ProbeResult{
"account_1": {
OK: false,
Status: "failed",
Message: "账号本身可正常使用,但当前测试接口仅支持 Responses API 路径。请直接通过实际 API 调用验证。",
},
},
models: map[string][]sub2api.AccountModel{
"account_1": {{ID: "deepseek-chat"}},
},
gatewayResult: sub2api.GatewayAccessResult{
OK: true,
StatusCode: 200,
HasExpectedModel: true,
Models: []string{"deepseek-chat"},
CompletionOK: true,
CompletionStatus: 200,
CompletionType: "text/event-stream",
},
}
report, err := NewImportService(host).Import(context.Background(), ImportRequest{
Provider: sampleProviderManifest(),
Mode: ImportModePartial,
Access: AccessRequest{Mode: AccessModeSelfService, ProbeAPIKey: "user-key"},
Keys: []string{"key-1"},
})
if err != nil {
t.Fatalf("Import() error = %v", err)
}
if report.BatchStatus != BatchStatusSucceeded {
t.Fatalf("BatchStatus = %q, want %q", report.BatchStatus, BatchStatusSucceeded)
}
if report.ProviderStatus != ProviderStatusActive {
t.Fatalf("ProviderStatus = %q, want %q", report.ProviderStatus, ProviderStatusActive)
}
if report.AccessStatus != AccessStatusSelfServiceReady {
t.Fatalf("AccessStatus = %q, want %q", report.AccessStatus, AccessStatusSelfServiceReady)
}
if len(report.Accounts) != 1 {
t.Fatalf("Accounts len = %d, want 1", len(report.Accounts))
}
if got := report.Accounts[0].ValidationStatus(); got != AccountStatusWarning {
t.Fatalf("ValidationStatus = %q, want %q", got, AccountStatusWarning)
}
}
func TestImportServiceTreatsTransientProbeFailureAsAdvisoryWhenGatewaySucceeds(t *testing.T) {
host := &fakeHostAdapter{
batchAccounts: []sub2api.AccountRef{{ID: "account_1", Name: "deepseek-01"}},
testResults: map[string]sub2api.ProbeResult{
"account_1": {
OK: false,
Status: "failed",
Message: "API returned 429: {\"error\":{\"message\":\"Rate limited (429); user=1997/2000 model=49/50; daily_exhausted=False\",\"type\":\"rate_limit_error\",\"code\":\"rate_limit_exceeded\"}}",
},
},
models: map[string][]sub2api.AccountModel{
"account_1": {{ID: "deepseek-chat"}},
},
gatewayResult: sub2api.GatewayAccessResult{
OK: true,
StatusCode: 200,
HasExpectedModel: true,
Models: []string{"deepseek-chat"},
CompletionOK: true,
CompletionStatus: 200,
CompletionType: "application/json",
},
}
report, err := NewImportService(host).Import(context.Background(), ImportRequest{
Provider: sampleProviderManifest(),
Mode: ImportModePartial,
Access: AccessRequest{Mode: AccessModeSelfService, ProbeAPIKey: "user-key"},
Keys: []string{"key-1"},
})
if err != nil {
t.Fatalf("Import() error = %v", err)
}
if report.BatchStatus != BatchStatusSucceeded {
t.Fatalf("BatchStatus = %q, want %q", report.BatchStatus, BatchStatusSucceeded)
}
if report.ProviderStatus != ProviderStatusActive {
t.Fatalf("ProviderStatus = %q, want %q", report.ProviderStatus, ProviderStatusActive)
}
if report.AccessStatus != AccessStatusSelfServiceReady {
t.Fatalf("AccessStatus = %q, want %q", report.AccessStatus, AccessStatusSelfServiceReady)
}
if got := report.Accounts[0].ValidationStatus(); got != AccountStatusWarning {
t.Fatalf("ValidationStatus = %q, want %q", got, AccountStatusWarning)
}
}
func TestImportServiceTreatsForbiddenProbeRaceAsAdvisoryWhenGatewaySucceeds(t *testing.T) {
host := &fakeHostAdapter{
batchAccounts: []sub2api.AccountRef{{ID: "account_1", Name: "kimi-a7m-01"}},
testResults: map[string]sub2api.ProbeResult{
"account_1": {
OK: false,
Status: "failed",
Message: "API returned 403: Forbidden",
},
},
models: map[string][]sub2api.AccountModel{
"account_1": {{ID: "deepseek-chat"}},
},
gatewayResult: sub2api.GatewayAccessResult{
OK: true,
StatusCode: 200,
HasExpectedModel: true,
Models: []string{"deepseek-chat"},
CompletionOK: true,
CompletionStatus: 200,
CompletionType: "application/json",
},
}
report, err := NewImportService(host).Import(context.Background(), ImportRequest{
Provider: sampleProviderManifest(),
Mode: ImportModePartial,
Access: AccessRequest{Mode: AccessModeSelfService, ProbeAPIKey: "user-key"},
Keys: []string{"key-1"},
})
if err != nil {
t.Fatalf("Import() error = %v", err)
}
if report.BatchStatus != BatchStatusSucceeded {
t.Fatalf("BatchStatus = %q, want %q", report.BatchStatus, BatchStatusSucceeded)
}
if report.ProviderStatus != ProviderStatusActive {
t.Fatalf("ProviderStatus = %q, want %q", report.ProviderStatus, ProviderStatusActive)
}
if report.AccessStatus != AccessStatusSelfServiceReady {
t.Fatalf("AccessStatus = %q, want %q", report.AccessStatus, AccessStatusSelfServiceReady)
}
if got := report.Accounts[0].ValidationStatus(); got != AccountStatusWarning {
t.Fatalf("ValidationStatus = %q, want %q", got, AccountStatusWarning)
}
}
func TestImportServiceKeepsProviderActiveWhenGatewayReadyDespiteSingleAccountProbeFailure(t *testing.T) {
host := &fakeHostAdapter{
batchAccounts: []sub2api.AccountRef{{ID: "account_1", Name: "minimax-01"}},
testResults: map[string]sub2api.ProbeResult{
"account_1": {
OK: false,
Status: "failed",
Message: `API returned 401: {"code":"INVALID_API_KEY","message":"Invalid API key"}`,
},
},
models: map[string][]sub2api.AccountModel{
"account_1": {{ID: "MiniMax-M2.7-highspeed"}},
},
gatewayResult: sub2api.GatewayAccessResult{
OK: true,
StatusCode: 200,
HasExpectedModel: true,
Models: []string{"MiniMax-M2.5-highspeed", "MiniMax-M2.7-highspeed"},
CompletionOK: true,
CompletionStatus: 200,
CompletionType: "text/event-stream",
},
}
report, err := NewImportService(host).Import(context.Background(), ImportRequest{
Provider: sampleProviderManifestWithSmokeModel("MiniMax-M2.7-highspeed"),
Mode: ImportModePartial,
Access: AccessRequest{
Mode: AccessModeSubscription,
ProbeAPIKey: "user-key",
Subscriptions: []SubscriptionTarget{{UserID: "user_1", DurationDays: 30}},
},
Keys: []string{"key-1"},
})
if err != nil {
t.Fatalf("Import() error = %v", err)
}
if report.BatchStatus != BatchStatusPartial {
t.Fatalf("BatchStatus = %q, want %q", report.BatchStatus, BatchStatusPartial)
}
if report.ProviderStatus != ProviderStatusActive {
t.Fatalf("ProviderStatus = %q, want %q", report.ProviderStatus, ProviderStatusActive)
}
if report.AccessStatus != AccessStatusSubscriptionReady {
t.Fatalf("AccessStatus = %q, want %q", report.AccessStatus, AccessStatusSubscriptionReady)
}
if got := report.Accounts[0].ValidationStatus(); got != AccountStatusFailed {
t.Fatalf("ValidationStatus = %q, want %q", got, AccountStatusFailed)
}
}
func TestImportServiceTreatsBare404ProbeAsAdvisoryWhenGatewaySucceeds(t *testing.T) {
host := &fakeHostAdapter{
batchAccounts: []sub2api.AccountRef{{ID: "account_1", Name: "deepseek-01"}},
testResults: map[string]sub2api.ProbeResult{
"account_1": {
OK: false,
Status: "failed",
Message: "API returned 404:",
},
},
models: map[string][]sub2api.AccountModel{
"account_1": {{ID: "deepseek-chat"}},
},
gatewayResult: sub2api.GatewayAccessResult{
OK: true,
StatusCode: 200,
HasExpectedModel: true,
Models: []string{"deepseek-chat"},
CompletionOK: true,
CompletionStatus: 200,
CompletionType: "application/json",
},
}
report, err := NewImportService(host).Import(context.Background(), ImportRequest{
Provider: sampleProviderManifest(),
Mode: ImportModePartial,
Access: AccessRequest{Mode: AccessModeSelfService, ProbeAPIKey: "user-key"},
Keys: []string{"key-1"},
})
if err != nil {
t.Fatalf("Import() error = %v", err)
}
if report.BatchStatus != BatchStatusSucceeded {
t.Fatalf("BatchStatus = %q, want %q", report.BatchStatus, BatchStatusSucceeded)
}
if report.ProviderStatus != ProviderStatusActive {
t.Fatalf("ProviderStatus = %q, want %q", report.ProviderStatus, ProviderStatusActive)
}
if report.AccessStatus != AccessStatusSelfServiceReady {
t.Fatalf("AccessStatus = %q, want %q", report.AccessStatus, AccessStatusSelfServiceReady)
}
if got := report.Accounts[0].ValidationStatus(); got != AccountStatusWarning {
t.Fatalf("ValidationStatus = %q, want %q", got, AccountStatusWarning)
}
}
func TestImportServiceRetriesTransientGatewayCompletionFailure(t *testing.T) {
host := &fakeHostAdapter{
batchAccounts: []sub2api.AccountRef{{ID: "account_1", Name: "kimi-a7m-01"}},
testResults: map[string]sub2api.ProbeResult{
"account_1": {
OK: false,
Status: "failed",
Message: "API returned 403: Forbidden",
},
},
models: map[string][]sub2api.AccountModel{
"account_1": {{ID: "deepseek-chat"}},
},
gatewayResult: sub2api.GatewayAccessResult{
OK: true,
StatusCode: 200,
HasExpectedModel: true,
Models: []string{"deepseek-chat"},
},
completionResults: []sub2api.GatewayCompletionResult{
{OK: false, StatusCode: 503, ContentType: "application/json", BodyPreview: `{"error":{"message":"Service temporarily unavailable","type":"api_error"}}`},
{OK: true, StatusCode: 200, ContentType: "application/json"},
},
}
report, err := NewImportService(host).Import(context.Background(), ImportRequest{
Provider: sampleProviderManifest(),
Mode: ImportModePartial,
Access: AccessRequest{Mode: AccessModeSelfService, ProbeAPIKey: "user-key"},
Keys: []string{"key-1"},
})
if err != nil {
t.Fatalf("Import() error = %v", err)
}
if report.BatchStatus != BatchStatusSucceeded {
t.Fatalf("BatchStatus = %q, want %q", report.BatchStatus, BatchStatusSucceeded)
}
if report.ProviderStatus != ProviderStatusActive {
t.Fatalf("ProviderStatus = %q, want %q", report.ProviderStatus, ProviderStatusActive)
}
if report.AccessStatus != AccessStatusSelfServiceReady {
t.Fatalf("AccessStatus = %q, want %q", report.AccessStatus, AccessStatusSelfServiceReady)
}
if !report.Gateway.CompletionOK || report.Gateway.CompletionStatus != 200 {
t.Fatalf("Gateway completion = %+v, want retried success", report.Gateway)
}
if host.completionCalls != 2 {
t.Fatalf("completion calls = %d, want 2", host.completionCalls)
}
}
func TestImportServiceRepairsOpenAIResponsesCapabilityMismatchAfterInstall(t *testing.T) {
host := &fakeHostAdapter{
batchAccounts: []sub2api.AccountRef{{ID: "account_1", Name: "kimi-a7m-01"}},
testResults: map[string]sub2api.ProbeResult{
"account_1": {
OK: false,
Status: "failed",
Message: "API returned 403: Forbidden",
},
},
models: map[string][]sub2api.AccountModel{
"account_1": {{ID: "deepseek-chat"}},
},
gatewayResult: sub2api.GatewayAccessResult{
OK: true,
StatusCode: 200,
HasExpectedModel: true,
Models: []string{"deepseek-chat"},
},
completionResults: []sub2api.GatewayCompletionResult{
{OK: false, StatusCode: 502, ContentType: "application/json", BodyPreview: `{"error":{"message":"Upstream service temporarily unavailable","type":"upstream_error"}}`},
},
completionAfterRepair: &sub2api.GatewayCompletionResult{OK: true, StatusCode: 200, ContentType: "application/json"},
}
report, err := NewImportService(host).Import(context.Background(), ImportRequest{
Provider: sampleProviderManifest(),
Mode: ImportModePartial,
Access: AccessRequest{Mode: AccessModeSelfService, ProbeAPIKey: "user-key"},
Keys: []string{"key-1"},
})
if err != nil {
t.Fatalf("Import() error = %v", err)
}
if !report.Gateway.CompletionOK || report.Gateway.CompletionStatus != 200 {
t.Fatalf("Gateway completion = %+v, want repaired success", report.Gateway)
}
if host.disableResponsesCalls != 1 {
t.Fatalf("disable responses calls = %d, want 1", host.disableResponsesCalls)
}
if len(host.disabledResponsesAccountIDs) != 1 || host.disabledResponsesAccountIDs[0] != "account_1" {
t.Fatalf("disabled responses account ids = %v, want [account_1]", host.disabledResponsesAccountIDs)
}
if host.clearTempUnschedulableCalls != 1 {
t.Fatalf("clear temp unschedulable calls = %d, want 1", host.clearTempUnschedulableCalls)
}
if len(host.clearedTempUnschedulableAccountIDs) != 1 || host.clearedTempUnschedulableAccountIDs[0] != "account_1" {
t.Fatalf("cleared temp unschedulable account ids = %v, want [account_1]", host.clearedTempUnschedulableAccountIDs)
}
}
func TestImportServicePreemptivelyDisablesResponsesForChatOnlyProvider(t *testing.T) {
host := &fakeHostAdapter{
batchAccounts: []sub2api.AccountRef{{ID: "account_1", Name: "kimi-a7m-01"}},
testResults: map[string]sub2api.ProbeResult{
"account_1": {OK: true, Status: "passed"},
},
models: map[string][]sub2api.AccountModel{
"account_1": {{ID: "kimi-k2.6"}},
},
gatewayResult: sub2api.GatewayAccessResult{
OK: true,
StatusCode: 200,
HasExpectedModel: true,
Models: []string{"kimi-k2.6"},
CompletionOK: true,
CompletionStatus: 200,
},
}
report, err := NewImportService(host).Import(context.Background(), ImportRequest{
Provider: sampleChatOnlyProviderManifest(),
Mode: ImportModePartial,
Access: AccessRequest{Mode: AccessModeSelfService, ProbeAPIKey: "user-key"},
Keys: []string{"key-1"},
})
if err != nil {
t.Fatalf("Import() error = %v", err)
}
if report.BatchStatus != BatchStatusSucceeded {
t.Fatalf("BatchStatus = %q, want %q", report.BatchStatus, BatchStatusSucceeded)
}
if host.disableResponsesCalls != 1 {
t.Fatalf("disable responses calls = %d, want 1", host.disableResponsesCalls)
}
if len(host.disabledResponsesAccountIDs) != 1 || host.disabledResponsesAccountIDs[0] != "account_1" {
t.Fatalf("disabled responses account ids = %v, want [account_1]", host.disabledResponsesAccountIDs)
}
if host.clearTempUnschedulableCalls != 1 {
t.Fatalf("clear temp unschedulable calls = %d, want 1", host.clearTempUnschedulableCalls)
}
if got := host.callSequence; len(got) == 0 || got[0] != "disable_responses" {
t.Fatalf("callSequence = %v, want preemptive capability repair before gateway validation", got)
}
}
func TestImportServiceStrictModeRollsBackCreatedResources(t *testing.T) {
host := &fakeHostAdapter{
batchAccounts: []sub2api.AccountRef{{ID: "account_1"}, {ID: "account_2"}},
testResults: map[string]sub2api.ProbeResult{
"account_1": {OK: true, Status: "passed"},
"account_2": {OK: false, Status: "failed", Message: "bad key"},
},
models: map[string][]sub2api.AccountModel{
"account_1": {{ID: "deepseek-chat"}},
"account_2": {{ID: "deepseek-chat"}},
},
}
svc := NewImportService(host)
_, err := svc.Import(context.Background(), ImportRequest{
Provider: sampleProviderManifest(),
Mode: ImportModeStrict,
Access: AccessRequest{Mode: AccessModeSelfService, ProbeAPIKey: "user-key"},
Keys: []string{"key-1", "key-2"},
})
if err == nil {
t.Fatal("Import() error = nil, want strict mode failure")
}
want := []string{"account:account_2", "account:account_1", "channel:channel_1", "group:group_1"}
if !reflect.DeepEqual(host.deletedResources, want) {
t.Fatalf("deleted resources = %#v, want %#v", host.deletedResources, want)
}
}
func TestImportReusesExistingGroup(t *testing.T) {
host := &fakeHostAdapter{
batchAccounts: []sub2api.AccountRef{{ID: "account_1", Name: "deepseek-01"}},
testResults: map[string]sub2api.ProbeResult{
"account_1": {OK: true, Status: "ready"},
},
models: map[string][]sub2api.AccountModel{
"account_1": {{ID: "deepseek-chat"}},
},
gatewayResult: sub2api.GatewayAccessResult{OK: true, StatusCode: 200, HasExpectedModel: true, Models: []string{"deepseek-chat"}},
managedSnapshot: sub2api.ManagedResourceSnapshot{
Groups: []sub2api.NamedResource{{ID: "group_existing", Name: "DeepSeek 默认分组-self-service"}},
},
}
svc := NewImportService(host)
report, err := svc.Import(context.Background(), ImportRequest{
Provider: sampleProviderManifest(),
Mode: ImportModePartial,
Access: AccessRequest{Mode: AccessModeSelfService, ProbeAPIKey: "user-key"},
Keys: []string{"key-1"},
})
if err != nil {
t.Fatalf("Import() error = %v", err)
}
if report.Group.ID != "group_existing" {
t.Fatalf("Group.ID = %q, want reused group_existing", report.Group.ID)
}
if host.createGroupCalls != 0 {
t.Fatalf("CreateGroup() calls = %d, want 0 when group already exists", host.createGroupCalls)
}
if host.createChannelCalls != 1 {
t.Fatalf("CreateChannel() calls = %d, want 1", host.createChannelCalls)
}
}
func TestImportCreatesChannelWithManifestModelMapping(t *testing.T) {
host := &fakeHostAdapter{
batchAccounts: []sub2api.AccountRef{{ID: "account_1", Name: "deepseek-01"}},
testResults: map[string]sub2api.ProbeResult{
"account_1": {OK: true, Status: "ready"},
},
models: map[string][]sub2api.AccountModel{
"account_1": {{ID: "deepseek-chat"}},
},
gatewayResult: sub2api.GatewayAccessResult{OK: true, StatusCode: 200, HasExpectedModel: true, Models: []string{"deepseek-chat"}},
}
_, err := NewImportService(host).Import(context.Background(), ImportRequest{
Provider: sampleProviderManifest(),
Mode: ImportModePartial,
Access: AccessRequest{Mode: AccessModeSelfService, ProbeAPIKey: "user-key"},
Keys: []string{"key-1"},
})
if err != nil {
t.Fatalf("Import() error = %v", err)
}
if host.createChannelReq.Name != "DeepSeek 默认渠道-self-service" {
t.Fatalf("CreateChannel().Name = %q, want DeepSeek 默认渠道-self-service", host.createChannelReq.Name)
}
if len(host.createChannelReq.GroupIDs) != 1 || host.createChannelReq.GroupIDs[0] != "group_1" {
t.Fatalf("CreateChannel().GroupIDs = %v, want [group_1]", host.createChannelReq.GroupIDs)
}
if got := host.createChannelReq.ModelMapping["deepseek-chat"]; got != "deepseek-chat" {
t.Fatalf("CreateChannel().ModelMapping = %+v, want deepseek-chat passthrough", host.createChannelReq.ModelMapping)
}
if !host.createChannelReq.RestrictModels {
t.Fatal("CreateChannel().RestrictModels = false, want true")
}
if host.createChannelReq.BillingModelSource != "channel_mapped" {
t.Fatalf("CreateChannel().BillingModelSource = %q, want channel_mapped", host.createChannelReq.BillingModelSource)
}
if len(host.createChannelReq.ModelPricing) != 1 {
t.Fatalf("CreateChannel().ModelPricing len = %d, want 1", len(host.createChannelReq.ModelPricing))
}
if len(host.createChannelReq.ModelPricing[0].Models) != 2 {
t.Fatalf("CreateChannel().ModelPricing[0].Models = %v, want default model coverage", host.createChannelReq.ModelPricing[0].Models)
}
if host.createChannelReq.ModelPricing[0].BillingMode != "token" {
t.Fatalf("CreateChannel().ModelPricing[0].BillingMode = %q, want token", host.createChannelReq.ModelPricing[0].BillingMode)
}
if len(host.batchCreateReq.Accounts) != 1 {
t.Fatalf("BatchCreateAccounts().Accounts len = %d, want 1", len(host.batchCreateReq.Accounts))
}
credentials := host.batchCreateReq.Accounts[0].Credentials
switch rawMapping := credentials["model_mapping"].(type) {
case map[string]string:
if got := rawMapping["deepseek-chat"]; got != "deepseek-chat" {
t.Fatalf("BatchCreateAccounts().Credentials.model_mapping = %+v, want deepseek-chat passthrough", rawMapping)
}
case map[string]any:
if got, _ := rawMapping["deepseek-chat"].(string); got != "deepseek-chat" {
t.Fatalf("BatchCreateAccounts().Credentials.model_mapping = %+v, want deepseek-chat passthrough", rawMapping)
}
default:
t.Fatalf("BatchCreateAccounts().Credentials = %+v, want model_mapping map", credentials)
}
}
func sampleProviderManifest() pack.ProviderManifest {
return pack.ProviderManifest{
ProviderID: "deepseek",
DisplayName: "DeepSeek OpenAI Compatible",
BaseURL: "https://api.deepseek.com",
Platform: "openai",
AccountType: "apikey",
DefaultModels: []string{"deepseek-chat", "deepseek-reasoner"},
SmokeTestModel: "deepseek-chat",
GroupTemplate: pack.GroupTemplate{Name: "DeepSeek 默认分组", RateMultiplier: 1},
ChannelTemplate: pack.ChannelTemplate{Name: "DeepSeek 默认渠道", ModelMapping: map[string]string{"deepseek-chat": "deepseek-chat"}},
PlanTemplate: pack.PlanTemplate{Name: "DeepSeek 默认套餐", Price: 19.9, ValidityDays: 30, ValidityUnit: "day"},
}
}
func sampleProviderManifestWithSmokeModel(smokeModel string) pack.ProviderManifest {
provider := sampleProviderManifest()
provider.ProviderID = "minimax-53hk"
provider.DisplayName = "MiniMax 53hk"
provider.BaseURL = "https://api.minimax.chat/v1"
provider.DefaultModels = []string{"MiniMax-M2.5-highspeed", strings.TrimSpace(smokeModel)}
provider.SmokeTestModel = strings.TrimSpace(smokeModel)
provider.ChannelTemplate = pack.ChannelTemplate{
Name: "MiniMax 53hk 默认渠道",
ModelMapping: map[string]string{
"MiniMax-M2.5-highspeed": "MiniMax-M2.5-highspeed",
strings.TrimSpace(smokeModel): strings.TrimSpace(smokeModel),
},
}
provider.GroupTemplate = pack.GroupTemplate{Name: "MiniMax 53hk 默认分组", RateMultiplier: 1}
provider.PlanTemplate = pack.PlanTemplate{Name: "MiniMax 53hk 默认套餐", Price: 19.9, ValidityDays: 30, ValidityUnit: "day"}
return provider
}
func sampleChatOnlyProviderManifest() pack.ProviderManifest {
provider := sampleProviderManifest()
provider.ProviderID = "kimi-a7m"
provider.DisplayName = "Kimi A7M OpenAI Compatible"
provider.BaseURL = "https://kimi.a7m.com.cn/v1"
provider.DefaultModels = []string{"kimi-k2.6"}
provider.SmokeTestModel = "kimi-k2.6"
provider.ChannelTemplate = pack.ChannelTemplate{Name: "Kimi A7M 默认渠道", ModelMapping: map[string]string{"kimi-k2.6": "kimi-k2.6"}}
provider.GroupTemplate = pack.GroupTemplate{Name: "Kimi A7M 默认分组", RateMultiplier: 1}
provider.PlanTemplate = pack.PlanTemplate{Name: "Kimi A7M 默认套餐", Price: 19.9, ValidityDays: 30, ValidityUnit: "day"}
provider.ForceDisableOpenAIResponsesAPI = true
return provider
}
func TestImportServicePreemptiveResponsesRepairHappensBeforeAccountProbe(t *testing.T) {
host := &fakeHostAdapter{
batchAccounts: []sub2api.AccountRef{{ID: "account_1", Name: "kimi-a7m-01"}},
testResults: map[string]sub2api.ProbeResult{
"account_1": {OK: true, Status: "passed"},
},
models: map[string][]sub2api.AccountModel{
"account_1": {{ID: "kimi-k2.6"}},
},
gatewayResult: sub2api.GatewayAccessResult{
OK: true,
StatusCode: 200,
HasExpectedModel: true,
Models: []string{"kimi-k2.6"},
CompletionOK: true,
CompletionStatus: 200,
},
}
report, err := NewImportService(host).Import(context.Background(), ImportRequest{
Provider: sampleChatOnlyProviderManifest(),
Mode: ImportModePartial,
Access: AccessRequest{Mode: AccessModeSelfService, ProbeAPIKey: "user-key"},
Keys: []string{"key-1"},
})
if err != nil {
t.Fatalf("Import() error = %v", err)
}
if report.BatchStatus != BatchStatusSucceeded {
t.Fatalf("BatchStatus = %q, want %q", report.BatchStatus, BatchStatusSucceeded)
}
wantSequence := []string{"disable_responses", "clear_temp_unschedulable", "testAccount:account_1", "gateway", "completion"}
if !reflect.DeepEqual(host.callSequence, wantSequence) {
t.Fatalf("callSequence = %v, want %v", host.callSequence, wantSequence)
}
if host.disableResponsesCalls != 1 {
t.Fatalf("disable responses calls = %d, want 1", host.disableResponsesCalls)
}
if len(host.disabledResponsesAccountIDs) != 1 || host.disabledResponsesAccountIDs[0] != "account_1" {
t.Fatalf("disabled responses account ids = %v, want [account_1]", host.disabledResponsesAccountIDs)
}
if host.clearTempUnschedulableCalls != 1 {
t.Fatalf("clear temp unschedulable calls = %d, want 1", host.clearTempUnschedulableCalls)
}
}
func TestImportReconcilesExistingChannelConfiguration(t *testing.T) {
host := &fakeHostAdapter{
batchAccounts: []sub2api.AccountRef{{ID: "account_1", Name: "deepseek-01"}},
testResults: map[string]sub2api.ProbeResult{
"account_1": {OK: true, Status: "ready"},
},
models: map[string][]sub2api.AccountModel{
"account_1": {{ID: "deepseek-chat"}},
},
gatewayResult: sub2api.GatewayAccessResult{OK: true, StatusCode: 200, HasExpectedModel: true, Models: []string{"deepseek-chat"}},
managedSnapshot: sub2api.ManagedResourceSnapshot{
Groups: []sub2api.NamedResource{{ID: "group_existing", Name: "DeepSeek 默认分组-self-service"}},
Channels: []sub2api.NamedResource{{ID: "channel_existing", Name: "DeepSeek 默认渠道-self-service"}},
},
}
_, err := NewImportService(host).Import(context.Background(), ImportRequest{
Provider: sampleProviderManifest(),
Mode: ImportModePartial,
Access: AccessRequest{Mode: AccessModeSelfService, ProbeAPIKey: "user-key"},
Keys: []string{"key-1"},
})
if err != nil {
t.Fatalf("Import() error = %v", err)
}
if host.createChannelCalls != 0 {
t.Fatalf("CreateChannel() calls = %d, want 0 when channel already exists", host.createChannelCalls)
}
if host.updateChannelCalls != 1 {
t.Fatalf("UpdateChannel() calls = %d, want 1", host.updateChannelCalls)
}
if host.updateChannelID != "channel_existing" {
t.Fatalf("UpdateChannel() id = %q, want channel_existing", host.updateChannelID)
}
if len(host.updateChannelReq.ModelPricing) != 1 {
t.Fatalf("UpdateChannel().ModelPricing len = %d, want 1", len(host.updateChannelReq.ModelPricing))
}
}
func TestImportDeletesExistingProviderAccountsBeforeGatewayClosure(t *testing.T) {
host := &fakeHostAdapter{
batchAccounts: []sub2api.AccountRef{{ID: "account_new_1", Name: "deepseek-01"}},
testResults: map[string]sub2api.ProbeResult{
"account_new_1": {OK: true, Status: "passed"},
},
models: map[string][]sub2api.AccountModel{
"account_new_1": {{ID: "deepseek-chat"}},
},
gatewayResult: sub2api.GatewayAccessResult{OK: true, StatusCode: 200, HasExpectedModel: true, Models: []string{"deepseek-chat"}},
managedSnapshot: sub2api.ManagedResourceSnapshot{
Accounts: []sub2api.NamedResource{{ID: "account_old_1", Name: "deepseek-01"}, {ID: "account_old_2", Name: "deepseek-02"}},
},
}
_, err := NewImportService(host).Import(context.Background(), ImportRequest{
Provider: sampleProviderManifest(),
Mode: ImportModePartial,
Access: AccessRequest{Mode: AccessModeSelfService, ProbeAPIKey: "user-key"},
Keys: []string{"key-1"},
})
if err != nil {
t.Fatalf("Import() error = %v", err)
}
if host.listManagedReq.AccountNamePrefix != "deepseek-" {
t.Fatalf("AccountNamePrefix = %q, want %q", host.listManagedReq.AccountNamePrefix, "deepseek-")
}
wantDeleted := []string{"account:account_old_2", "account:account_old_1"}
if !reflect.DeepEqual(host.deletedResources, wantDeleted) {
t.Fatalf("deleted resources = %#v, want %#v", host.deletedResources, wantDeleted)
}
if !reflect.DeepEqual(host.callSequence, []string{"testAccount:account_new_1", "deleteAccount:account_old_2", "deleteAccount:account_old_1", "gateway", "completion"}) {
t.Fatalf("call sequence = %#v, want stale-account cleanup before gateway probe", host.callSequence)
}
}
func TestImportKeepsExistingAccountsWhenReplacementValidationFails(t *testing.T) {
host := &fakeHostAdapter{
batchAccounts: []sub2api.AccountRef{{ID: "account_new_1", Name: "deepseek-01"}},
testResults: map[string]sub2api.ProbeResult{
"account_new_1": {OK: false, Status: "failed", Message: "bad key"},
},
models: map[string][]sub2api.AccountModel{
"account_new_1": {{ID: "deepseek-chat"}},
},
gatewayResult: sub2api.GatewayAccessResult{OK: true, StatusCode: 200, HasExpectedModel: true, Models: []string{"deepseek-chat"}},
managedSnapshot: sub2api.ManagedResourceSnapshot{
Accounts: []sub2api.NamedResource{{ID: "account_old_1", Name: "deepseek-01"}},
},
}
report, err := NewImportService(host).Import(context.Background(), ImportRequest{
Provider: sampleProviderManifest(),
Mode: ImportModePartial,
Access: AccessRequest{Mode: AccessModeSelfService, ProbeAPIKey: "user-key"},
Keys: []string{"key-1"},
})
if err != nil {
t.Fatalf("Import() error = %v", err)
}
if report.BatchStatus != BatchStatusPartial {
t.Fatalf("BatchStatus = %q, want %q", report.BatchStatus, BatchStatusPartial)
}
if len(host.deletedResources) != 0 {
t.Fatalf("deleted resources = %#v, want no stale-account cleanup when replacement validation fails", host.deletedResources)
}
}
type fakeHostAdapter struct {
batchAccounts []sub2api.AccountRef
batchCreateReq sub2api.BatchCreateAccountsRequest
testResults map[string]sub2api.ProbeResult
models map[string][]sub2api.AccountModel
gatewayResult sub2api.GatewayAccessResult
batchCreateErr error
assignErr error
gatewayErr error
hostVersion string
assignedSubscriptions []sub2api.AssignSubscriptionRequest
gatewayProbe sub2api.GatewayAccessCheckRequest
completionProbe sub2api.GatewayCompletionCheckRequest
deletedResources []string
managedSnapshot sub2api.ManagedResourceSnapshot
listManagedReq sub2api.ListManagedResourcesRequest
createGroupCalls int
createChannelCalls int
updateChannelCalls int
createPlanCalls int
createGroupReq sub2api.CreateGroupRequest
createChannelReq sub2api.CreateChannelRequest
updateChannelID string
updateChannelReq sub2api.CreateChannelRequest
callSequence []string
completionCalls int
completionResults []sub2api.GatewayCompletionResult
completionResult sub2api.GatewayCompletionResult
completionAfterRepair *sub2api.GatewayCompletionResult
completionErr error
testedModels map[string]string
disableResponsesCalls int
disabledResponsesAccountIDs []string
clearTempUnschedulableCalls int
clearedTempUnschedulableAccountIDs []string
deleteErrors map[string]error
}
func (f *fakeHostAdapter) GetHostVersion(context.Context) (string, error) {
if strings.TrimSpace(f.hostVersion) == "" {
return "0.1.126", nil
}
return f.hostVersion, nil
}
func (f *fakeHostAdapter) ProbeCapabilities(context.Context) (sub2api.HostCapabilities, error) {
return sub2api.HostCapabilities{}, nil
}
func (f *fakeHostAdapter) CreateGroup(_ context.Context, req sub2api.CreateGroupRequest) (sub2api.GroupRef, error) {
f.createGroupCalls++
f.createGroupReq = req
return sub2api.GroupRef{ID: "group_1", Name: "g"}, nil
}
func (f *fakeHostAdapter) DeleteGroup(_ context.Context, groupID string) error {
if err := f.deleteErrors["group:"+groupID]; err != nil {
return err
}
f.deletedResources = append(f.deletedResources, "group:"+groupID)
return nil
}
func (f *fakeHostAdapter) CreateChannel(_ context.Context, req sub2api.CreateChannelRequest) (sub2api.ChannelRef, error) {
f.createChannelCalls++
f.createChannelReq = req
return sub2api.ChannelRef{ID: "channel_1", Name: "c"}, nil
}
func (f *fakeHostAdapter) UpdateChannel(_ context.Context, channelID string, req sub2api.CreateChannelRequest) error {
f.updateChannelCalls++
f.updateChannelID = channelID
f.updateChannelReq = req
return nil
}
func (f *fakeHostAdapter) DeleteChannel(_ context.Context, channelID string) error {
if err := f.deleteErrors["channel:"+channelID]; err != nil {
return err
}
f.deletedResources = append(f.deletedResources, "channel:"+channelID)
return nil
}
func (f *fakeHostAdapter) CreatePlan(context.Context, sub2api.CreatePlanRequest) (sub2api.PlanRef, error) {
f.createPlanCalls++
return sub2api.PlanRef{ID: "plan_1", Name: "p"}, nil
}
func (f *fakeHostAdapter) DeletePlan(_ context.Context, planID string) error {
if err := f.deleteErrors["plan:"+planID]; err != nil {
return err
}
f.deletedResources = append(f.deletedResources, "plan:"+planID)
return nil
}
func (f *fakeHostAdapter) CreateAccount(context.Context, sub2api.CreateAccountRequest) (sub2api.AccountRef, error) {
return sub2api.AccountRef{}, errors.New("unused")
}
func (f *fakeHostAdapter) BatchCreateAccounts(_ context.Context, req sub2api.BatchCreateAccountsRequest) ([]sub2api.AccountRef, error) {
f.batchCreateReq = req
if f.batchCreateErr != nil {
return nil, f.batchCreateErr
}
return f.batchAccounts, nil
}
func (f *fakeHostAdapter) DeleteAccount(_ context.Context, accountID string) error {
if err := f.deleteErrors["account:"+accountID]; err != nil {
return err
}
f.callSequence = append(f.callSequence, "deleteAccount:"+accountID)
f.deletedResources = append(f.deletedResources, "account:"+accountID)
return nil
}
func (f *fakeHostAdapter) TestAccount(_ context.Context, accountID, modelID string) (sub2api.ProbeResult, error) {
f.callSequence = append(f.callSequence, "testAccount:"+accountID)
if f.testedModels == nil {
f.testedModels = map[string]string{}
}
f.testedModels[accountID] = modelID
result, ok := f.testResults[accountID]
if !ok {
return sub2api.ProbeResult{}, fmt.Errorf("missing test result for %s", accountID)
}
return result, nil
}
func (f *fakeHostAdapter) GetAccountModels(_ context.Context, accountID string) ([]sub2api.AccountModel, error) {
models, ok := f.models[accountID]
if !ok {
return nil, fmt.Errorf("missing models for %s", accountID)
}
return models, nil
}
func (f *fakeHostAdapter) EnsureSubscriptionAccess(_ context.Context, req sub2api.EnsureSubscriptionAccessRequest) (sub2api.SubscriptionAccessRef, error) {
return sub2api.SubscriptionAccessRef{UserID: req.UserSelector, APIKey: "managed-subscription-key"}, nil
}
func (f *fakeHostAdapter) AssignSubscription(_ context.Context, req sub2api.AssignSubscriptionRequest) (sub2api.SubscriptionRef, error) {
if f.assignErr != nil {
return sub2api.SubscriptionRef{}, f.assignErr
}
f.assignedSubscriptions = append(f.assignedSubscriptions, req)
return sub2api.SubscriptionRef{ID: "subscription_1"}, nil
}
func (f *fakeHostAdapter) CheckGatewayAccess(_ context.Context, req sub2api.GatewayAccessCheckRequest) (sub2api.GatewayAccessResult, error) {
f.callSequence = append(f.callSequence, "gateway")
f.gatewayProbe = req
if f.gatewayErr != nil {
return sub2api.GatewayAccessResult{}, f.gatewayErr
}
return f.gatewayResult, nil
}
func (f *fakeHostAdapter) CheckGatewayCompletion(_ context.Context, req sub2api.GatewayCompletionCheckRequest) (sub2api.GatewayCompletionResult, error) {
f.callSequence = append(f.callSequence, "completion")
f.completionProbe = req
f.completionCalls++
if f.completionErr != nil {
return sub2api.GatewayCompletionResult{}, f.completionErr
}
if f.disableResponsesCalls > 0 && f.completionAfterRepair != nil {
return *f.completionAfterRepair, nil
}
if len(f.completionResults) > 0 {
idx := f.completionCalls - 1
if idx >= len(f.completionResults) {
idx = len(f.completionResults) - 1
}
return f.completionResults[idx], nil
}
if f.completionResult.StatusCode == 0 && !f.completionResult.OK {
return sub2api.GatewayCompletionResult{OK: true, StatusCode: 200, ContentType: "application/json"}, nil
}
return f.completionResult, nil
}
func (f *fakeHostAdapter) DisableOpenAIResponsesAPI(_ context.Context, accountIDs []string) error {
f.callSequence = append(f.callSequence, "disable_responses")
f.disableResponsesCalls++
f.disabledResponsesAccountIDs = append([]string(nil), accountIDs...)
return nil
}
func (f *fakeHostAdapter) ClearTempUnschedulable(_ context.Context, accountIDs []string) error {
f.callSequence = append(f.callSequence, "clear_temp_unschedulable")
f.clearTempUnschedulableCalls++
f.clearedTempUnschedulableAccountIDs = append([]string(nil), accountIDs...)
return nil
}
func (f *fakeHostAdapter) ListManagedResources(_ context.Context, req sub2api.ListManagedResourcesRequest) (sub2api.ManagedResourceSnapshot, error) {
f.listManagedReq = req
return sub2api.ManagedResourceSnapshot{
Groups: filterNamedResourcesByExactName(f.managedSnapshot.Groups, req.GroupName),
Channels: filterNamedResourcesByExactName(f.managedSnapshot.Channels, req.ChannelName),
Plans: filterNamedResourcesByExactName(f.managedSnapshot.Plans, req.PlanName),
Accounts: filterNamedResourcesByPrefix(f.managedSnapshot.Accounts, req.AccountNamePrefix),
}, nil
}
func filterNamedResourcesByExactName(resources []sub2api.NamedResource, expected string) []sub2api.NamedResource {
expected = strings.TrimSpace(expected)
if expected == "" {
return nil
}
filtered := make([]sub2api.NamedResource, 0, len(resources))
for _, resource := range resources {
if strings.TrimSpace(resource.Name) == expected {
filtered = append(filtered, resource)
}
}
return filtered
}
func filterNamedResourcesByPrefix(resources []sub2api.NamedResource, prefix string) []sub2api.NamedResource {
prefix = strings.TrimSpace(prefix)
if prefix == "" {
return resources
}
filtered := make([]sub2api.NamedResource, 0, len(resources))
for _, resource := range resources {
if strings.HasPrefix(strings.TrimSpace(resource.Name), prefix) {
filtered = append(filtered, resource)
}
}
return filtered
}