gateway_group_isolation_test.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "testing"
  6. "github.com/Wei-Shaw/sub2api/internal/config"
  7. "github.com/stretchr/testify/require"
  8. )
  9. // ============================================================================
  10. // Part 1: isAccountInGroup 单元测试
  11. // ============================================================================
  12. func TestIsAccountInGroup(t *testing.T) {
  13. svc := &GatewayService{}
  14. groupID100 := int64(100)
  15. groupID200 := int64(200)
  16. tests := []struct {
  17. name string
  18. account *Account
  19. groupID *int64
  20. expected bool
  21. }{
  22. // groupID == nil(无分组 API Key)
  23. {
  24. "nil_groupID_ungrouped_account_nil_groups",
  25. &Account{ID: 1, AccountGroups: nil},
  26. nil, true,
  27. },
  28. {
  29. "nil_groupID_ungrouped_account_empty_slice",
  30. &Account{ID: 2, AccountGroups: []AccountGroup{}},
  31. nil, true,
  32. },
  33. {
  34. "nil_groupID_grouped_account_single",
  35. &Account{ID: 3, AccountGroups: []AccountGroup{{GroupID: 100}}},
  36. nil, false,
  37. },
  38. {
  39. "nil_groupID_grouped_account_multiple",
  40. &Account{ID: 4, AccountGroups: []AccountGroup{{GroupID: 100}, {GroupID: 200}}},
  41. nil, false,
  42. },
  43. // groupID != nil(有分组 API Key)
  44. {
  45. "with_groupID_account_in_group",
  46. &Account{ID: 5, AccountGroups: []AccountGroup{{GroupID: 100}}},
  47. &groupID100, true,
  48. },
  49. {
  50. "with_groupID_account_not_in_group",
  51. &Account{ID: 6, AccountGroups: []AccountGroup{{GroupID: 200}}},
  52. &groupID100, false,
  53. },
  54. {
  55. "with_groupID_ungrouped_account",
  56. &Account{ID: 7, AccountGroups: nil},
  57. &groupID100, false,
  58. },
  59. {
  60. "with_groupID_multi_group_account_match_one",
  61. &Account{ID: 8, AccountGroups: []AccountGroup{{GroupID: 100}, {GroupID: 200}}},
  62. &groupID200, true,
  63. },
  64. {
  65. "with_groupID_multi_group_account_no_match",
  66. &Account{ID: 9, AccountGroups: []AccountGroup{{GroupID: 300}, {GroupID: 400}}},
  67. &groupID100, false,
  68. },
  69. // 防御性边界
  70. {
  71. "nil_account_nil_groupID",
  72. nil,
  73. nil, false,
  74. },
  75. {
  76. "nil_account_with_groupID",
  77. nil,
  78. &groupID100, false,
  79. },
  80. }
  81. for _, tt := range tests {
  82. t.Run(tt.name, func(t *testing.T) {
  83. got := svc.isAccountInGroup(tt.account, tt.groupID)
  84. require.Equal(t, tt.expected, got, "isAccountInGroup 结果不符预期")
  85. })
  86. }
  87. }
  88. // ============================================================================
  89. // Part 2: 分组隔离端到端调度测试
  90. // ============================================================================
  91. // groupAwareMockAccountRepo 嵌入 mockAccountRepoForPlatform,覆写分组隔离相关方法。
  92. // allAccounts 存储所有账号,分组查询方法按 AccountGroups 字段进行真实过滤。
  93. type groupAwareMockAccountRepo struct {
  94. *mockAccountRepoForPlatform
  95. allAccounts []Account
  96. }
  97. // ListSchedulableUngroupedByPlatform 仅返回未分组账号(AccountGroups 为空)
  98. func (m *groupAwareMockAccountRepo) ListSchedulableUngroupedByPlatform(ctx context.Context, platform string) ([]Account, error) {
  99. var result []Account
  100. for _, acc := range m.allAccounts {
  101. if acc.Platform == platform && acc.IsSchedulable() && len(acc.AccountGroups) == 0 {
  102. result = append(result, acc)
  103. }
  104. }
  105. return result, nil
  106. }
  107. // ListSchedulableUngroupedByPlatforms 仅返回未分组账号(多平台版本)
  108. func (m *groupAwareMockAccountRepo) ListSchedulableUngroupedByPlatforms(ctx context.Context, platforms []string) ([]Account, error) {
  109. platformSet := make(map[string]bool, len(platforms))
  110. for _, p := range platforms {
  111. platformSet[p] = true
  112. }
  113. var result []Account
  114. for _, acc := range m.allAccounts {
  115. if platformSet[acc.Platform] && acc.IsSchedulable() && len(acc.AccountGroups) == 0 {
  116. result = append(result, acc)
  117. }
  118. }
  119. return result, nil
  120. }
  121. // ListSchedulableByGroupIDAndPlatform 返回属于指定分组的账号
  122. func (m *groupAwareMockAccountRepo) ListSchedulableByGroupIDAndPlatform(ctx context.Context, groupID int64, platform string) ([]Account, error) {
  123. var result []Account
  124. for _, acc := range m.allAccounts {
  125. if acc.Platform == platform && acc.IsSchedulable() && accountBelongsToGroup(acc, groupID) {
  126. result = append(result, acc)
  127. }
  128. }
  129. return result, nil
  130. }
  131. // ListSchedulableByGroupIDAndPlatforms 返回属于指定分组的账号(多平台版本)
  132. func (m *groupAwareMockAccountRepo) ListSchedulableByGroupIDAndPlatforms(ctx context.Context, groupID int64, platforms []string) ([]Account, error) {
  133. platformSet := make(map[string]bool, len(platforms))
  134. for _, p := range platforms {
  135. platformSet[p] = true
  136. }
  137. var result []Account
  138. for _, acc := range m.allAccounts {
  139. if platformSet[acc.Platform] && acc.IsSchedulable() && accountBelongsToGroup(acc, groupID) {
  140. result = append(result, acc)
  141. }
  142. }
  143. return result, nil
  144. }
  145. // accountBelongsToGroup 检查账号是否属于指定分组
  146. func accountBelongsToGroup(acc Account, groupID int64) bool {
  147. for _, ag := range acc.AccountGroups {
  148. if ag.GroupID == groupID {
  149. return true
  150. }
  151. }
  152. return false
  153. }
  154. // Verify interface implementation
  155. var _ AccountRepository = (*groupAwareMockAccountRepo)(nil)
  156. // newGroupAwareMockRepo 创建分组感知的 mock repo
  157. func newGroupAwareMockRepo(accounts []Account) *groupAwareMockAccountRepo {
  158. byID := make(map[int64]*Account, len(accounts))
  159. for i := range accounts {
  160. byID[accounts[i].ID] = &accounts[i]
  161. }
  162. return &groupAwareMockAccountRepo{
  163. mockAccountRepoForPlatform: &mockAccountRepoForPlatform{
  164. accounts: accounts,
  165. accountsByID: byID,
  166. },
  167. allAccounts: accounts,
  168. }
  169. }
  170. func TestGroupIsolation_UngroupedKey_ShouldNotScheduleGroupedAccounts(t *testing.T) {
  171. // 场景:无分组 API Key(groupID=nil),池中只有已分组账号 → 应返回错误
  172. ctx := context.Background()
  173. accounts := []Account{
  174. {ID: 1, Platform: PlatformOpenAI, Priority: 1, Status: StatusActive, Schedulable: true,
  175. AccountGroups: []AccountGroup{{GroupID: 100}}},
  176. {ID: 2, Platform: PlatformOpenAI, Priority: 2, Status: StatusActive, Schedulable: true,
  177. AccountGroups: []AccountGroup{{GroupID: 200}}},
  178. }
  179. repo := newGroupAwareMockRepo(accounts)
  180. cache := &mockGatewayCacheForPlatform{}
  181. svc := &GatewayService{
  182. accountRepo: repo,
  183. cache: cache,
  184. cfg: testConfig(),
  185. }
  186. acc, err := svc.selectAccountForModelWithPlatform(ctx, nil, "", "", nil, PlatformOpenAI)
  187. require.Error(t, err, "无分组 Key 不应调度到已分组账号")
  188. require.Nil(t, acc)
  189. }
  190. func TestGroupIsolation_GroupedKey_ShouldNotScheduleUngroupedAccounts(t *testing.T) {
  191. // 场景:有分组 API Key(groupID=100),池中只有未分组账号 → 应返回错误
  192. ctx := context.Background()
  193. groupID := int64(100)
  194. accounts := []Account{
  195. {ID: 1, Platform: PlatformOpenAI, Priority: 1, Status: StatusActive, Schedulable: true,
  196. AccountGroups: nil},
  197. {ID: 2, Platform: PlatformOpenAI, Priority: 2, Status: StatusActive, Schedulable: true,
  198. AccountGroups: []AccountGroup{}},
  199. }
  200. repo := newGroupAwareMockRepo(accounts)
  201. cache := &mockGatewayCacheForPlatform{}
  202. svc := &GatewayService{
  203. accountRepo: repo,
  204. cache: cache,
  205. cfg: testConfig(),
  206. }
  207. acc, err := svc.selectAccountForModelWithPlatform(ctx, &groupID, "", "", nil, PlatformOpenAI)
  208. require.Error(t, err, "有分组 Key 不应调度到未分组账号")
  209. require.Nil(t, acc)
  210. }
  211. func TestGroupIsolation_UngroupedKey_ShouldOnlyScheduleUngroupedAccounts(t *testing.T) {
  212. // 场景:无分组 API Key(groupID=nil),池中有未分组和已分组账号 → 应只选中未分组的
  213. ctx := context.Background()
  214. accounts := []Account{
  215. {ID: 1, Platform: PlatformOpenAI, Priority: 1, Status: StatusActive, Schedulable: true,
  216. AccountGroups: []AccountGroup{{GroupID: 100}}}, // 已分组,不应被选中
  217. {ID: 2, Platform: PlatformOpenAI, Priority: 2, Status: StatusActive, Schedulable: true,
  218. AccountGroups: nil}, // 未分组,应被选中
  219. {ID: 3, Platform: PlatformOpenAI, Priority: 3, Status: StatusActive, Schedulable: true,
  220. AccountGroups: []AccountGroup{{GroupID: 200}}}, // 已分组,不应被选中
  221. }
  222. repo := newGroupAwareMockRepo(accounts)
  223. cache := &mockGatewayCacheForPlatform{}
  224. svc := &GatewayService{
  225. accountRepo: repo,
  226. cache: cache,
  227. cfg: testConfig(),
  228. }
  229. acc, err := svc.selectAccountForModelWithPlatform(ctx, nil, "", "", nil, PlatformOpenAI)
  230. require.NoError(t, err, "应成功调度未分组账号")
  231. require.NotNil(t, acc)
  232. require.Equal(t, int64(2), acc.ID, "应选中未分组的账号 ID=2")
  233. }
  234. func TestGroupIsolation_GroupedKey_ShouldOnlyScheduleMatchingGroupAccounts(t *testing.T) {
  235. // 场景:有分组 API Key(groupID=100),池中有未分组和多个分组账号 → 应只选中分组 100 内的
  236. ctx := context.Background()
  237. groupID := int64(100)
  238. accounts := []Account{
  239. {ID: 1, Platform: PlatformOpenAI, Priority: 1, Status: StatusActive, Schedulable: true,
  240. AccountGroups: nil}, // 未分组,不应被选中
  241. {ID: 2, Platform: PlatformOpenAI, Priority: 2, Status: StatusActive, Schedulable: true,
  242. AccountGroups: []AccountGroup{{GroupID: 200}}}, // 属于分组 200,不应被选中
  243. {ID: 3, Platform: PlatformOpenAI, Priority: 3, Status: StatusActive, Schedulable: true,
  244. AccountGroups: []AccountGroup{{GroupID: 100}}}, // 属于分组 100,应被选中
  245. }
  246. repo := newGroupAwareMockRepo(accounts)
  247. cache := &mockGatewayCacheForPlatform{}
  248. svc := &GatewayService{
  249. accountRepo: repo,
  250. cache: cache,
  251. cfg: testConfig(),
  252. }
  253. acc, err := svc.selectAccountForModelWithPlatform(ctx, &groupID, "", "", nil, PlatformOpenAI)
  254. require.NoError(t, err, "应成功调度分组内账号")
  255. require.NotNil(t, acc)
  256. require.Equal(t, int64(3), acc.ID, "应选中分组 100 内的账号 ID=3")
  257. }
  258. // ============================================================================
  259. // Part 3: SimpleMode 旁路测试
  260. // ============================================================================
  261. func TestGroupIsolation_SimpleMode_SkipsGroupIsolation(t *testing.T) {
  262. // SimpleMode 应跳过分组隔离,使用 ListSchedulableByPlatform 返回所有账号。
  263. // 测试非 useMixed 路径(platform=openai,不会触发 mixed 调度逻辑)。
  264. ctx := context.Background()
  265. // 混合未分组和已分组账号,SimpleMode 下应全部可调度
  266. accounts := []Account{
  267. {ID: 1, Platform: PlatformOpenAI, Priority: 2, Status: StatusActive, Schedulable: true,
  268. AccountGroups: []AccountGroup{{GroupID: 100}}}, // 已分组
  269. {ID: 2, Platform: PlatformOpenAI, Priority: 1, Status: StatusActive, Schedulable: true,
  270. AccountGroups: nil}, // 未分组
  271. }
  272. // 使用基础 mock(ListSchedulableByPlatform 返回所有匹配平台的账号,不做分组过滤)
  273. byID := make(map[int64]*Account, len(accounts))
  274. for i := range accounts {
  275. byID[accounts[i].ID] = &accounts[i]
  276. }
  277. repo := &mockAccountRepoForPlatform{
  278. accounts: accounts,
  279. accountsByID: byID,
  280. }
  281. cache := &mockGatewayCacheForPlatform{}
  282. svc := &GatewayService{
  283. accountRepo: repo,
  284. cache: cache,
  285. cfg: &config.Config{RunMode: config.RunModeSimple},
  286. }
  287. // groupID=nil 时,SimpleMode 应使用 ListSchedulableByPlatform(不过滤分组)
  288. acc, err := svc.selectAccountForModelWithPlatform(ctx, nil, "", "", nil, PlatformOpenAI)
  289. require.NoError(t, err, "SimpleMode 应跳过分组隔离直接返回账号")
  290. require.NotNil(t, acc)
  291. // 应选择优先级最高的账号(Priority=1, ID=2),即使它未分组
  292. require.Equal(t, int64(2), acc.ID, "SimpleMode 应按优先级选择,不考虑分组")
  293. }
  294. func TestGroupIsolation_SimpleMode_GroupedAccountAlsoSchedulable(t *testing.T) {
  295. // SimpleMode + groupID=nil 时,已分组账号也应该可被调度
  296. ctx := context.Background()
  297. // 只有已分组账号,在 standard 模式下 groupID=nil 会报错,但 simple 模式应正常
  298. accounts := []Account{
  299. {ID: 1, Platform: PlatformOpenAI, Priority: 1, Status: StatusActive, Schedulable: true,
  300. AccountGroups: []AccountGroup{{GroupID: 100}}},
  301. }
  302. byID := make(map[int64]*Account, len(accounts))
  303. for i := range accounts {
  304. byID[accounts[i].ID] = &accounts[i]
  305. }
  306. repo := &mockAccountRepoForPlatform{
  307. accounts: accounts,
  308. accountsByID: byID,
  309. }
  310. cache := &mockGatewayCacheForPlatform{}
  311. svc := &GatewayService{
  312. accountRepo: repo,
  313. cache: cache,
  314. cfg: &config.Config{RunMode: config.RunModeSimple},
  315. }
  316. acc, err := svc.selectAccountForModelWithPlatform(ctx, nil, "", "", nil, PlatformOpenAI)
  317. require.NoError(t, err, "SimpleMode 下已分组账号也应可调度")
  318. require.NotNil(t, acc)
  319. require.Equal(t, int64(1), acc.ID, "SimpleMode 应能调度已分组账号")
  320. }