admin_group_duplicate_test.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "errors"
  6. "strings"
  7. "testing"
  8. "time"
  9. "unicode/utf8"
  10. "github.com/stretchr/testify/require"
  11. )
  12. type duplicateGroupRepoStub struct {
  13. GroupRepository
  14. nextID int64
  15. groups map[int64]*Group
  16. names map[string]struct{}
  17. byOperation map[string]int64
  18. sourceBindings map[int64][]AccountGroup
  19. createdBindings map[int64][]AccountGroup
  20. createdFromSources []int64
  21. atomicCreateErr error
  22. }
  23. func newDuplicateGroupRepoStub(source *Group) *duplicateGroupRepoStub {
  24. repo := &duplicateGroupRepoStub{
  25. nextID: 100,
  26. groups: make(map[int64]*Group),
  27. names: make(map[string]struct{}),
  28. byOperation: make(map[string]int64),
  29. sourceBindings: make(map[int64][]AccountGroup),
  30. createdBindings: make(map[int64][]AccountGroup),
  31. }
  32. if source != nil {
  33. repo.groups[source.ID] = source
  34. repo.names[source.Name] = struct{}{}
  35. }
  36. return repo
  37. }
  38. func cloneGroupForDuplicateTest(group *Group) *Group {
  39. if group == nil {
  40. return nil
  41. }
  42. cloned := *group
  43. cloned.DailyLimitUSD = cloneGroupValuePointer(group.DailyLimitUSD)
  44. cloned.WeeklyLimitUSD = cloneGroupValuePointer(group.WeeklyLimitUSD)
  45. cloned.MonthlyLimitUSD = cloneGroupValuePointer(group.MonthlyLimitUSD)
  46. cloned.ImagePrice1K = cloneGroupValuePointer(group.ImagePrice1K)
  47. cloned.ImagePrice2K = cloneGroupValuePointer(group.ImagePrice2K)
  48. cloned.ImagePrice4K = cloneGroupValuePointer(group.ImagePrice4K)
  49. cloned.VideoPrice480P = cloneGroupValuePointer(group.VideoPrice480P)
  50. cloned.VideoPrice720P = cloneGroupValuePointer(group.VideoPrice720P)
  51. cloned.VideoPrice1080P = cloneGroupValuePointer(group.VideoPrice1080P)
  52. cloned.WebSearchPricePerCall = cloneGroupValuePointer(group.WebSearchPricePerCall)
  53. cloned.FallbackGroupID = cloneGroupValuePointer(group.FallbackGroupID)
  54. cloned.FallbackGroupIDOnInvalidRequest = cloneGroupValuePointer(group.FallbackGroupIDOnInvalidRequest)
  55. cloned.ModelRouting = cloneGroupModelRouting(group.ModelRouting)
  56. cloned.SupportedModelScopes = append([]string(nil), group.SupportedModelScopes...)
  57. cloned.MessagesDispatchModelConfig = cloneGroupMessagesDispatchModelConfig(group.MessagesDispatchModelConfig)
  58. cloned.ModelAllowlist.Models = append([]string(nil), group.ModelAllowlist.Models...)
  59. cloned.AccountGroups = append([]AccountGroup(nil), group.AccountGroups...)
  60. return &cloned
  61. }
  62. func (r *duplicateGroupRepoStub) GetByID(_ context.Context, id int64) (*Group, error) {
  63. group := r.groups[id]
  64. if group == nil {
  65. return nil, ErrGroupNotFound
  66. }
  67. cloned := cloneGroupForDuplicateTest(group)
  68. cloned.Hydrated = true
  69. return cloned, nil
  70. }
  71. func (r *duplicateGroupRepoStub) FindByDuplicateOperationID(_ context.Context, operationID string) (*Group, error) {
  72. id := r.byOperation[operationID]
  73. if id == 0 {
  74. return nil, nil
  75. }
  76. return cloneGroupForDuplicateTest(r.groups[id]), nil
  77. }
  78. func (r *duplicateGroupRepoStub) CreateFromSource(_ context.Context, group *Group, sourceGroupID int64) error {
  79. if r.atomicCreateErr != nil {
  80. return r.atomicCreateErr
  81. }
  82. if group.DuplicateOperationID != "" {
  83. if _, exists := r.byOperation[group.DuplicateOperationID]; exists {
  84. return ErrGroupExists
  85. }
  86. }
  87. if _, exists := r.names[group.Name]; exists {
  88. return ErrGroupExists
  89. }
  90. r.nextID++
  91. group.ID = r.nextID
  92. group.CreatedAt = time.Now().UTC()
  93. group.UpdatedAt = group.CreatedAt
  94. bindings := append([]AccountGroup(nil), r.sourceBindings[sourceGroupID]...)
  95. for i := range bindings {
  96. bindings[i].GroupID = group.ID
  97. }
  98. group.AccountCount = int64(len(bindings))
  99. group.ActiveAccountCount = int64(len(bindings))
  100. r.createdBindings[group.ID] = bindings
  101. r.createdFromSources = append(r.createdFromSources, sourceGroupID)
  102. r.names[group.Name] = struct{}{}
  103. r.groups[group.ID] = cloneGroupForDuplicateTest(group)
  104. if group.DuplicateOperationID != "" {
  105. r.byOperation[group.DuplicateOperationID] = group.ID
  106. }
  107. return nil
  108. }
  109. func groupDuplicateTestPointer[T any](value T) *T { return &value }
  110. func TestDuplicateGroupCopiesConfigurationDeeplyAndResetsRuntimeState(t *testing.T) {
  111. createdAt := time.Date(2026, time.July, 1, 2, 3, 4, 0, time.UTC)
  112. source := &Group{
  113. ID: 41,
  114. Name: "高级订阅",
  115. Description: "configuration",
  116. Platform: PlatformOpenAI,
  117. RateMultiplier: 1.75,
  118. PeakRateEnabled: true,
  119. PeakStart: "09:00",
  120. PeakEnd: "18:00",
  121. PeakRateMultiplier: 1.2,
  122. IsExclusive: true,
  123. Status: StatusActive,
  124. Hydrated: true,
  125. SubscriptionType: SubscriptionTypeSubscription,
  126. DailyLimitUSD: groupDuplicateTestPointer(11.0),
  127. WeeklyLimitUSD: groupDuplicateTestPointer(22.0),
  128. MonthlyLimitUSD: groupDuplicateTestPointer(33.0),
  129. DefaultValidityDays: 91,
  130. AllowImageGeneration: true,
  131. AllowBatchImageGeneration: true,
  132. ImageRateIndependent: true,
  133. ImageRateMultiplier: 1.4,
  134. ImagePrice1K: groupDuplicateTestPointer(0.01),
  135. ImagePrice2K: groupDuplicateTestPointer(0.02),
  136. ImagePrice4K: groupDuplicateTestPointer(0.04),
  137. BatchImageDiscountMultiplier: 0.4,
  138. BatchImageHoldMultiplier: 0.7,
  139. VideoRateIndependent: true,
  140. VideoRateMultiplier: 2.1,
  141. VideoPrice480P: groupDuplicateTestPointer(0.1),
  142. VideoPrice720P: groupDuplicateTestPointer(0.2),
  143. VideoPrice1080P: groupDuplicateTestPointer(0.3),
  144. VideoModelPrices: map[string]map[string]float64{
  145. VideoPriceFamilyGrokImagineVideo15: {VideoBillingResolution720P: 0.14},
  146. },
  147. WebSearchPricePerCall: groupDuplicateTestPointer(0.005),
  148. ClaudeCodeOnly: true,
  149. FallbackGroupID: groupDuplicateTestPointer(int64(7)),
  150. FallbackGroupIDOnInvalidRequest: groupDuplicateTestPointer(int64(8)),
  151. ModelRouting: map[string][]int64{"gpt-*": {13, 17}},
  152. ModelRoutingEnabled: true,
  153. MCPXMLInject: true,
  154. SupportedModelScopes: []string{"claude", "gemini_text"},
  155. SortOrder: 9,
  156. AllowMessagesDispatch: true,
  157. AllowLive: true,
  158. ForceOpenAIFast: true,
  159. FreeOpenAIFast: true,
  160. RequireOAuthOnly: true,
  161. RequirePrivacySet: true,
  162. DefaultMappedModel: "gpt-5.4",
  163. MessagesDispatchModelConfig: OpenAIMessagesDispatchModelConfig{
  164. OpusMappedModel: "gpt-5.4",
  165. SonnetMappedModel: "gpt-5.3",
  166. HaikuMappedModel: "gpt-5-mini",
  167. ExactModelMappings: map[string]string{"claude-special": "gpt-special"},
  168. },
  169. ModelAllowlist: GroupModelAllowlist{Enabled: true, Models: []string{"gpt-5.4", "gpt-5-mini"}},
  170. RPMLimit: 99,
  171. MaxReasoningEffort: "medium",
  172. MaxReasoningEffortOverLimit: ReasoningEffortOverLimitDeny,
  173. ReasoningEffortMappings: []ReasoningEffortMapping{{From: "max", To: "xhigh"}},
  174. CreatedAt: createdAt,
  175. UpdatedAt: createdAt,
  176. AccountCount: 12,
  177. ActiveAccountCount: 8,
  178. RateLimitedAccountCount: 2,
  179. DuplicateOperationID: "old-operation-must-not-copy",
  180. AccountGroups: []AccountGroup{{AccountID: 13, GroupID: 41, Priority: 37}},
  181. }
  182. repo := newDuplicateGroupRepoStub(source)
  183. repo.sourceBindings[source.ID] = []AccountGroup{
  184. {AccountID: 13, GroupID: source.ID, Priority: 37},
  185. {AccountID: 17, GroupID: source.ID, Priority: 8},
  186. }
  187. svc := &adminServiceImpl{groupRepo: repo, groupDuplicateRepo: repo}
  188. duplicate, err := svc.DuplicateGroup(context.Background(), source.ID, "admin:7", "stable-key")
  189. require.NoError(t, err)
  190. require.NotEqual(t, source.ID, duplicate.ID)
  191. require.Equal(t, "高级订阅 (Copy)", duplicate.Name)
  192. require.Equal(t, duplicateGroupInactiveStatus, duplicate.Status)
  193. require.True(t, duplicate.Hydrated, "the duplicate response is reloaded with derived counts")
  194. require.Equal(t, source.Description, duplicate.Description)
  195. require.Equal(t, source.Platform, duplicate.Platform)
  196. require.Equal(t, source.RateMultiplier, duplicate.RateMultiplier)
  197. require.Equal(t, source.PeakRateMultiplier, duplicate.PeakRateMultiplier)
  198. require.Equal(t, source.DefaultValidityDays, duplicate.DefaultValidityDays)
  199. require.Equal(t, source.ImagePrice4K, duplicate.ImagePrice4K)
  200. require.Equal(t, source.VideoModelPrices, duplicate.VideoModelPrices)
  201. require.Equal(t, source.WebSearchPricePerCall, duplicate.WebSearchPricePerCall)
  202. require.Equal(t, source.FallbackGroupID, duplicate.FallbackGroupID)
  203. require.Equal(t, source.ModelRouting, duplicate.ModelRouting)
  204. require.Equal(t, source.MessagesDispatchModelConfig, duplicate.MessagesDispatchModelConfig)
  205. require.Equal(t, source.ForceOpenAIFast, duplicate.ForceOpenAIFast)
  206. require.Equal(t, source.FreeOpenAIFast, duplicate.FreeOpenAIFast)
  207. require.Equal(t, source.ModelAllowlist, duplicate.ModelAllowlist)
  208. require.Equal(t, source.RPMLimit, duplicate.RPMLimit)
  209. require.Equal(t, source.MaxReasoningEffort, duplicate.MaxReasoningEffort)
  210. require.Equal(t, source.MaxReasoningEffortOverLimit, duplicate.MaxReasoningEffortOverLimit)
  211. require.Equal(t, source.ReasoningEffortMappings, duplicate.ReasoningEffortMappings)
  212. require.EqualValues(t, 2, duplicate.AccountCount)
  213. require.EqualValues(t, 2, duplicate.ActiveAccountCount)
  214. require.NotEmpty(t, duplicate.DuplicateOperationID)
  215. require.Equal(t, []int64{source.ID}, repo.createdFromSources)
  216. require.Equal(t, []AccountGroup{
  217. {AccountID: 13, GroupID: duplicate.ID, Priority: 37},
  218. {AccountID: 17, GroupID: duplicate.ID, Priority: 8},
  219. }, repo.createdBindings[duplicate.ID])
  220. duplicate.ModelRouting["gpt-*"][0] = 999
  221. duplicate.VideoModelPrices[VideoPriceFamilyGrokImagineVideo15][VideoBillingResolution720P] = 999
  222. duplicate.SupportedModelScopes[0] = "changed"
  223. duplicate.MessagesDispatchModelConfig.ExactModelMappings["claude-special"] = "changed"
  224. duplicate.ModelAllowlist.Models[0] = "changed"
  225. duplicate.ReasoningEffortMappings[0].To = "changed"
  226. *duplicate.DailyLimitUSD = 999
  227. require.Equal(t, int64(13), source.ModelRouting["gpt-*"][0])
  228. require.Equal(t, 0.14, source.VideoModelPrices[VideoPriceFamilyGrokImagineVideo15][VideoBillingResolution720P])
  229. require.Equal(t, "claude", source.SupportedModelScopes[0])
  230. require.Equal(t, "gpt-special", source.MessagesDispatchModelConfig.ExactModelMappings["claude-special"])
  231. require.Equal(t, "gpt-5.4", source.ModelAllowlist.Models[0])
  232. require.Equal(t, "xhigh", source.ReasoningEffortMappings[0].To)
  233. require.Equal(t, 11.0, *source.DailyLimitUSD)
  234. }
  235. func TestDuplicateGroupRecoversSameOperationAndScopesByAdmin(t *testing.T) {
  236. source := &Group{ID: 9, Name: "team", Platform: PlatformAnthropic, Status: StatusActive}
  237. repo := newDuplicateGroupRepoStub(source)
  238. svc := &adminServiceImpl{groupRepo: repo, groupDuplicateRepo: repo}
  239. ctx := context.Background()
  240. first, err := svc.DuplicateGroup(ctx, source.ID, "admin:7", "same-key")
  241. require.NoError(t, err)
  242. retry, err := svc.DuplicateGroup(ctx, source.ID, "admin:7", "same-key")
  243. require.NoError(t, err)
  244. recovered, err := svc.RecoverDuplicateGroup(ctx, source.ID, "admin:7", "same-key")
  245. require.NoError(t, err)
  246. otherAdmin, err := svc.DuplicateGroup(ctx, source.ID, "admin:8", "same-key")
  247. require.NoError(t, err)
  248. require.Equal(t, first.ID, retry.ID)
  249. require.Equal(t, first.ID, recovered.ID)
  250. require.NotEqual(t, first.ID, otherAdmin.ID)
  251. require.Equal(t, "team (Copy 2)", otherAdmin.Name)
  252. }
  253. func TestDuplicateGroupAdvancesNameAndTruncatesUnicodeByRunes(t *testing.T) {
  254. source := &Group{ID: 12, Name: "team", Platform: PlatformAnthropic, Status: StatusActive}
  255. repo := newDuplicateGroupRepoStub(source)
  256. repo.names["team (Copy)"] = struct{}{}
  257. svc := &adminServiceImpl{groupRepo: repo, groupDuplicateRepo: repo}
  258. duplicate, err := svc.DuplicateGroup(context.Background(), source.ID, "admin:1", "")
  259. require.NoError(t, err)
  260. require.Equal(t, "team (Copy 2)", duplicate.Name)
  261. unicodeName := duplicateGroupName(strings.Repeat("组", 100), 23)
  262. require.Equal(t, maxGroupNameRunes, utf8.RuneCountInString(unicodeName))
  263. require.True(t, strings.HasSuffix(unicodeName, " (Copy 23)"))
  264. }
  265. func TestDuplicateGroupAtomicCreateFailureReturnsNoCopy(t *testing.T) {
  266. source := &Group{ID: 15, Name: "team", Platform: PlatformAnthropic, Status: StatusActive}
  267. repo := newDuplicateGroupRepoStub(source)
  268. repo.atomicCreateErr = errors.New("binding insert failed")
  269. svc := &adminServiceImpl{groupRepo: repo, groupDuplicateRepo: repo}
  270. duplicate, err := svc.DuplicateGroup(context.Background(), source.ID, "admin:1", "key")
  271. require.ErrorContains(t, err, "binding insert failed")
  272. require.Nil(t, duplicate)
  273. require.Len(t, repo.groups, 1)
  274. require.Empty(t, repo.byOperation)
  275. }