445 lines
17 KiB
Go
445 lines
17 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")
|
|
}
|
|
}
|
|
|
|
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 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 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))
|
|
}
|
|
}
|
|
|
|
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
|
|
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
|
|
}
|
|
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
f.deletedResources = append(f.deletedResources, "account:"+accountID)
|
|
return nil
|
|
}
|
|
func (f *fakeHostAdapter) TestAccount(_ context.Context, accountID string) (sub2api.ProbeResult, error) {
|
|
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.gatewayProbe = req
|
|
if f.gatewayErr != nil {
|
|
return sub2api.GatewayAccessResult{}, f.gatewayErr
|
|
}
|
|
return f.gatewayResult, 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
|
|
}
|