Files
sub2api/backend/internal/service/admin_group_duplicate_test.go
T
李建琦 6d655c9903
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

293 lines
12 KiB
Go

//go:build unit
package service
import (
"context"
"errors"
"strings"
"testing"
"time"
"unicode/utf8"
"github.com/stretchr/testify/require"
)
type duplicateGroupRepoStub struct {
GroupRepository
nextID int64
groups map[int64]*Group
names map[string]struct{}
byOperation map[string]int64
sourceBindings map[int64][]AccountGroup
createdBindings map[int64][]AccountGroup
createdFromSources []int64
atomicCreateErr error
}
func newDuplicateGroupRepoStub(source *Group) *duplicateGroupRepoStub {
repo := &duplicateGroupRepoStub{
nextID: 100,
groups: make(map[int64]*Group),
names: make(map[string]struct{}),
byOperation: make(map[string]int64),
sourceBindings: make(map[int64][]AccountGroup),
createdBindings: make(map[int64][]AccountGroup),
}
if source != nil {
repo.groups[source.ID] = source
repo.names[source.Name] = struct{}{}
}
return repo
}
func cloneGroupForDuplicateTest(group *Group) *Group {
if group == nil {
return nil
}
cloned := *group
cloned.DailyLimitUSD = cloneGroupValuePointer(group.DailyLimitUSD)
cloned.WeeklyLimitUSD = cloneGroupValuePointer(group.WeeklyLimitUSD)
cloned.MonthlyLimitUSD = cloneGroupValuePointer(group.MonthlyLimitUSD)
cloned.ImagePrice1K = cloneGroupValuePointer(group.ImagePrice1K)
cloned.ImagePrice2K = cloneGroupValuePointer(group.ImagePrice2K)
cloned.ImagePrice4K = cloneGroupValuePointer(group.ImagePrice4K)
cloned.VideoPrice480P = cloneGroupValuePointer(group.VideoPrice480P)
cloned.VideoPrice720P = cloneGroupValuePointer(group.VideoPrice720P)
cloned.VideoPrice1080P = cloneGroupValuePointer(group.VideoPrice1080P)
cloned.WebSearchPricePerCall = cloneGroupValuePointer(group.WebSearchPricePerCall)
cloned.FallbackGroupID = cloneGroupValuePointer(group.FallbackGroupID)
cloned.FallbackGroupIDOnInvalidRequest = cloneGroupValuePointer(group.FallbackGroupIDOnInvalidRequest)
cloned.ModelRouting = cloneGroupModelRouting(group.ModelRouting)
cloned.SupportedModelScopes = append([]string(nil), group.SupportedModelScopes...)
cloned.MessagesDispatchModelConfig = cloneGroupMessagesDispatchModelConfig(group.MessagesDispatchModelConfig)
cloned.ModelsListConfig.Models = append([]string(nil), group.ModelsListConfig.Models...)
cloned.AccountGroups = append([]AccountGroup(nil), group.AccountGroups...)
return &cloned
}
func (r *duplicateGroupRepoStub) GetByID(_ context.Context, id int64) (*Group, error) {
group := r.groups[id]
if group == nil {
return nil, ErrGroupNotFound
}
cloned := cloneGroupForDuplicateTest(group)
cloned.Hydrated = true
return cloned, nil
}
func (r *duplicateGroupRepoStub) FindByDuplicateOperationID(_ context.Context, operationID string) (*Group, error) {
id := r.byOperation[operationID]
if id == 0 {
return nil, nil
}
return cloneGroupForDuplicateTest(r.groups[id]), nil
}
func (r *duplicateGroupRepoStub) CreateFromSource(_ context.Context, group *Group, sourceGroupID int64) error {
if r.atomicCreateErr != nil {
return r.atomicCreateErr
}
if group.DuplicateOperationID != "" {
if _, exists := r.byOperation[group.DuplicateOperationID]; exists {
return ErrGroupExists
}
}
if _, exists := r.names[group.Name]; exists {
return ErrGroupExists
}
r.nextID++
group.ID = r.nextID
group.CreatedAt = time.Now().UTC()
group.UpdatedAt = group.CreatedAt
bindings := append([]AccountGroup(nil), r.sourceBindings[sourceGroupID]...)
for i := range bindings {
bindings[i].GroupID = group.ID
}
group.AccountCount = int64(len(bindings))
group.ActiveAccountCount = int64(len(bindings))
r.createdBindings[group.ID] = bindings
r.createdFromSources = append(r.createdFromSources, sourceGroupID)
r.names[group.Name] = struct{}{}
r.groups[group.ID] = cloneGroupForDuplicateTest(group)
if group.DuplicateOperationID != "" {
r.byOperation[group.DuplicateOperationID] = group.ID
}
return nil
}
func groupDuplicateTestPointer[T any](value T) *T { return &value }
func TestDuplicateGroupCopiesConfigurationDeeplyAndResetsRuntimeState(t *testing.T) {
createdAt := time.Date(2026, time.July, 1, 2, 3, 4, 0, time.UTC)
source := &Group{
ID: 41,
Name: "高级订阅",
Description: "configuration",
Platform: PlatformOpenAI,
RateMultiplier: 1.75,
PeakRateEnabled: true,
PeakStart: "09:00",
PeakEnd: "18:00",
PeakRateMultiplier: 1.2,
IsExclusive: true,
Status: StatusActive,
Hydrated: true,
SubscriptionType: SubscriptionTypeSubscription,
DailyLimitUSD: groupDuplicateTestPointer(11.0),
WeeklyLimitUSD: groupDuplicateTestPointer(22.0),
MonthlyLimitUSD: groupDuplicateTestPointer(33.0),
DefaultValidityDays: 91,
AllowImageGeneration: true,
AllowBatchImageGeneration: true,
ImageRateIndependent: true,
ImageRateMultiplier: 1.4,
ImagePrice1K: groupDuplicateTestPointer(0.01),
ImagePrice2K: groupDuplicateTestPointer(0.02),
ImagePrice4K: groupDuplicateTestPointer(0.04),
BatchImageDiscountMultiplier: 0.4,
BatchImageHoldMultiplier: 0.7,
VideoRateIndependent: true,
VideoRateMultiplier: 2.1,
VideoPrice480P: groupDuplicateTestPointer(0.1),
VideoPrice720P: groupDuplicateTestPointer(0.2),
VideoPrice1080P: groupDuplicateTestPointer(0.3),
VideoModelPrices: map[string]map[string]float64{
VideoPriceFamilyGrokImagineVideo15: {VideoBillingResolution720P: 0.14},
},
WebSearchPricePerCall: groupDuplicateTestPointer(0.005),
ClaudeCodeOnly: true,
FallbackGroupID: groupDuplicateTestPointer(int64(7)),
FallbackGroupIDOnInvalidRequest: groupDuplicateTestPointer(int64(8)),
ModelRouting: map[string][]int64{"gpt-*": {13, 17}},
ModelRoutingEnabled: true,
MCPXMLInject: true,
SupportedModelScopes: []string{"claude", "gemini_text"},
SortOrder: 9,
AllowMessagesDispatch: true,
AllowLive: true,
RequireOAuthOnly: true,
RequirePrivacySet: true,
DefaultMappedModel: "gpt-5.4",
MessagesDispatchModelConfig: OpenAIMessagesDispatchModelConfig{
OpusMappedModel: "gpt-5.4",
SonnetMappedModel: "gpt-5.3",
HaikuMappedModel: "gpt-5-mini",
ExactModelMappings: map[string]string{"claude-special": "gpt-special"},
},
ModelsListConfig: GroupModelsListConfig{Enabled: true, Models: []string{"gpt-5.4", "gpt-5-mini"}},
RPMLimit: 99,
MaxReasoningEffort: "medium",
ReasoningEffortMappings: []ReasoningEffortMapping{{From: "max", To: "xhigh"}},
CreatedAt: createdAt,
UpdatedAt: createdAt,
AccountCount: 12,
ActiveAccountCount: 8,
RateLimitedAccountCount: 2,
DuplicateOperationID: "old-operation-must-not-copy",
AccountGroups: []AccountGroup{{AccountID: 13, GroupID: 41, Priority: 37}},
}
repo := newDuplicateGroupRepoStub(source)
repo.sourceBindings[source.ID] = []AccountGroup{
{AccountID: 13, GroupID: source.ID, Priority: 37},
{AccountID: 17, GroupID: source.ID, Priority: 8},
}
svc := &adminServiceImpl{groupRepo: repo, groupDuplicateRepo: repo}
duplicate, err := svc.DuplicateGroup(context.Background(), source.ID, "admin:7", "stable-key")
require.NoError(t, err)
require.NotEqual(t, source.ID, duplicate.ID)
require.Equal(t, "高级订阅 (Copy)", duplicate.Name)
require.Equal(t, duplicateGroupInactiveStatus, duplicate.Status)
require.True(t, duplicate.Hydrated, "the duplicate response is reloaded with derived counts")
require.Equal(t, source.Description, duplicate.Description)
require.Equal(t, source.Platform, duplicate.Platform)
require.Equal(t, source.RateMultiplier, duplicate.RateMultiplier)
require.Equal(t, source.PeakRateMultiplier, duplicate.PeakRateMultiplier)
require.Equal(t, source.DefaultValidityDays, duplicate.DefaultValidityDays)
require.Equal(t, source.ImagePrice4K, duplicate.ImagePrice4K)
require.Equal(t, source.VideoModelPrices, duplicate.VideoModelPrices)
require.Equal(t, source.WebSearchPricePerCall, duplicate.WebSearchPricePerCall)
require.Equal(t, source.FallbackGroupID, duplicate.FallbackGroupID)
require.Equal(t, source.ModelRouting, duplicate.ModelRouting)
require.Equal(t, source.MessagesDispatchModelConfig, duplicate.MessagesDispatchModelConfig)
require.Equal(t, source.ModelsListConfig, duplicate.ModelsListConfig)
require.Equal(t, source.RPMLimit, duplicate.RPMLimit)
require.Equal(t, source.MaxReasoningEffort, duplicate.MaxReasoningEffort)
require.Equal(t, source.ReasoningEffortMappings, duplicate.ReasoningEffortMappings)
require.EqualValues(t, 2, duplicate.AccountCount)
require.EqualValues(t, 2, duplicate.ActiveAccountCount)
require.NotEmpty(t, duplicate.DuplicateOperationID)
require.Equal(t, []int64{source.ID}, repo.createdFromSources)
require.Equal(t, []AccountGroup{
{AccountID: 13, GroupID: duplicate.ID, Priority: 37},
{AccountID: 17, GroupID: duplicate.ID, Priority: 8},
}, repo.createdBindings[duplicate.ID])
duplicate.ModelRouting["gpt-*"][0] = 999
duplicate.VideoModelPrices[VideoPriceFamilyGrokImagineVideo15][VideoBillingResolution720P] = 999
duplicate.SupportedModelScopes[0] = "changed"
duplicate.MessagesDispatchModelConfig.ExactModelMappings["claude-special"] = "changed"
duplicate.ModelsListConfig.Models[0] = "changed"
duplicate.ReasoningEffortMappings[0].To = "changed"
*duplicate.DailyLimitUSD = 999
require.Equal(t, int64(13), source.ModelRouting["gpt-*"][0])
require.Equal(t, 0.14, source.VideoModelPrices[VideoPriceFamilyGrokImagineVideo15][VideoBillingResolution720P])
require.Equal(t, "claude", source.SupportedModelScopes[0])
require.Equal(t, "gpt-special", source.MessagesDispatchModelConfig.ExactModelMappings["claude-special"])
require.Equal(t, "gpt-5.4", source.ModelsListConfig.Models[0])
require.Equal(t, "xhigh", source.ReasoningEffortMappings[0].To)
require.Equal(t, 11.0, *source.DailyLimitUSD)
}
func TestDuplicateGroupRecoversSameOperationAndScopesByAdmin(t *testing.T) {
source := &Group{ID: 9, Name: "team", Platform: PlatformAnthropic, Status: StatusActive}
repo := newDuplicateGroupRepoStub(source)
svc := &adminServiceImpl{groupRepo: repo, groupDuplicateRepo: repo}
ctx := context.Background()
first, err := svc.DuplicateGroup(ctx, source.ID, "admin:7", "same-key")
require.NoError(t, err)
retry, err := svc.DuplicateGroup(ctx, source.ID, "admin:7", "same-key")
require.NoError(t, err)
recovered, err := svc.RecoverDuplicateGroup(ctx, source.ID, "admin:7", "same-key")
require.NoError(t, err)
otherAdmin, err := svc.DuplicateGroup(ctx, source.ID, "admin:8", "same-key")
require.NoError(t, err)
require.Equal(t, first.ID, retry.ID)
require.Equal(t, first.ID, recovered.ID)
require.NotEqual(t, first.ID, otherAdmin.ID)
require.Equal(t, "team (Copy 2)", otherAdmin.Name)
}
func TestDuplicateGroupAdvancesNameAndTruncatesUnicodeByRunes(t *testing.T) {
source := &Group{ID: 12, Name: "team", Platform: PlatformAnthropic, Status: StatusActive}
repo := newDuplicateGroupRepoStub(source)
repo.names["team (Copy)"] = struct{}{}
svc := &adminServiceImpl{groupRepo: repo, groupDuplicateRepo: repo}
duplicate, err := svc.DuplicateGroup(context.Background(), source.ID, "admin:1", "")
require.NoError(t, err)
require.Equal(t, "team (Copy 2)", duplicate.Name)
unicodeName := duplicateGroupName(strings.Repeat("组", 100), 23)
require.Equal(t, maxGroupNameRunes, utf8.RuneCountInString(unicodeName))
require.True(t, strings.HasSuffix(unicodeName, " (Copy 23)"))
}
func TestDuplicateGroupAtomicCreateFailureReturnsNoCopy(t *testing.T) {
source := &Group{ID: 15, Name: "team", Platform: PlatformAnthropic, Status: StatusActive}
repo := newDuplicateGroupRepoStub(source)
repo.atomicCreateErr = errors.New("binding insert failed")
svc := &adminServiceImpl{groupRepo: repo, groupDuplicateRepo: repo}
duplicate, err := svc.DuplicateGroup(context.Background(), source.ID, "admin:1", "key")
require.ErrorContains(t, err, "binding insert failed")
require.Nil(t, duplicate)
require.Len(t, repo.groups, 1)
require.Empty(t, repo.byOperation)
}