group_repo_integration_test.go 42 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138
  1. //go:build integration
  2. package repository
  3. import (
  4. "context"
  5. "database/sql"
  6. "errors"
  7. "strings"
  8. "testing"
  9. "time"
  10. dbent "github.com/Wei-Shaw/sub2api/ent"
  11. "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
  12. "github.com/Wei-Shaw/sub2api/internal/service"
  13. "github.com/stretchr/testify/require"
  14. "github.com/stretchr/testify/suite"
  15. )
  16. type GroupRepoSuite struct {
  17. suite.Suite
  18. ctx context.Context
  19. tx *dbent.Tx
  20. repo *groupRepository
  21. }
  22. type forbidSQLExecutor struct {
  23. called bool
  24. }
  25. func (s *forbidSQLExecutor) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) {
  26. s.called = true
  27. return nil, errors.New("unexpected sql exec")
  28. }
  29. func (s *forbidSQLExecutor) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
  30. s.called = true
  31. return nil, errors.New("unexpected sql query")
  32. }
  33. func (s *GroupRepoSuite) SetupTest() {
  34. s.ctx = context.Background()
  35. tx := testEntTx(s.T())
  36. s.tx = tx
  37. s.repo = newGroupRepositoryWithSQL(tx.Client(), tx)
  38. }
  39. func TestGroupRepoSuite(t *testing.T) {
  40. suite.Run(t, new(GroupRepoSuite))
  41. }
  42. // --- Create / GetByID / Update / Delete ---
  43. func (s *GroupRepoSuite) TestCreate() {
  44. group := &service.Group{
  45. Name: "test-create",
  46. Platform: service.PlatformAnthropic,
  47. RateMultiplier: 1.0,
  48. IsExclusive: false,
  49. Status: service.StatusActive,
  50. SubscriptionType: service.SubscriptionTypeStandard,
  51. }
  52. err := s.repo.Create(s.ctx, group)
  53. s.Require().NoError(err, "Create")
  54. s.Require().NotZero(group.ID, "expected ID to be set")
  55. got, err := s.repo.GetByID(s.ctx, group.ID)
  56. s.Require().NoError(err, "GetByID")
  57. s.Require().Equal("test-create", got.Name)
  58. }
  59. func (s *GroupRepoSuite) TestCreateFromSourcePreservesPriorityAndFiltersIneligibleAccounts() {
  60. source := &service.Group{
  61. Name: "duplicate-source",
  62. Platform: service.PlatformOpenAI,
  63. RateMultiplier: 1,
  64. Status: service.StatusActive,
  65. SubscriptionType: service.SubscriptionTypeStandard,
  66. RequireOAuthOnly: true,
  67. }
  68. s.Require().NoError(s.repo.Create(s.ctx, source))
  69. insertAccount := func(name, accountType string, deleted bool) int64 {
  70. var id int64
  71. deletedAt := any(nil)
  72. if deleted {
  73. deletedAt = "2026-07-16T00:00:00Z"
  74. }
  75. s.Require().NoError(scanSingleRow(
  76. s.ctx,
  77. s.tx,
  78. "INSERT INTO accounts (name, platform, type, deleted_at) VALUES ($1, $2, $3, $4) RETURNING id",
  79. []any{name, service.PlatformOpenAI, accountType, deletedAt},
  80. &id,
  81. ))
  82. return id
  83. }
  84. oauthID := insertAccount("duplicate-oauth", service.AccountTypeOAuth, false)
  85. apiKeyID := insertAccount("duplicate-apikey", service.AccountTypeAPIKey, false)
  86. deletedID := insertAccount("duplicate-deleted", service.AccountTypeOAuth, true)
  87. for _, binding := range []struct {
  88. accountID int64
  89. priority int
  90. }{{oauthID, 37}, {apiKeyID, 8}, {deletedID, 3}} {
  91. _, err := s.tx.ExecContext(
  92. s.ctx,
  93. "INSERT INTO account_groups (account_id, group_id, priority, created_at) VALUES ($1, $2, $3, NOW())",
  94. binding.accountID,
  95. source.ID,
  96. binding.priority,
  97. )
  98. s.Require().NoError(err)
  99. }
  100. duplicate := &service.Group{
  101. Name: "duplicate-source (Copy)",
  102. Platform: source.Platform,
  103. RateMultiplier: source.RateMultiplier,
  104. Status: "inactive",
  105. SubscriptionType: source.SubscriptionType,
  106. RequireOAuthOnly: true,
  107. DuplicateOperationID: strings.Repeat("a", 64),
  108. }
  109. s.Require().NoError(s.repo.CreateFromSource(s.ctx, duplicate, source.ID))
  110. s.Require().EqualValues(1, duplicate.AccountCount)
  111. rows, err := s.tx.QueryContext(
  112. s.ctx,
  113. "SELECT account_id, priority FROM account_groups WHERE group_id = $1 ORDER BY account_id",
  114. duplicate.ID,
  115. )
  116. s.Require().NoError(err)
  117. defer func() { _ = rows.Close() }()
  118. s.Require().True(rows.Next())
  119. var copiedAccountID int64
  120. var copiedPriority int
  121. s.Require().NoError(rows.Scan(&copiedAccountID, &copiedPriority))
  122. s.Require().Equal(oauthID, copiedAccountID)
  123. s.Require().Equal(37, copiedPriority)
  124. s.Require().False(rows.Next(), "API-key and soft-deleted accounts must not be copied")
  125. recovered, err := s.repo.FindByDuplicateOperationID(s.ctx, duplicate.DuplicateOperationID)
  126. s.Require().NoError(err)
  127. s.Require().Equal(duplicate.ID, recovered.ID)
  128. var outboxCount int
  129. s.Require().NoError(scanSingleRow(
  130. s.ctx,
  131. s.tx,
  132. "SELECT COUNT(*) FROM scheduler_outbox WHERE group_id = $1",
  133. []any{duplicate.ID},
  134. &outboxCount,
  135. ))
  136. s.Require().Equal(1, outboxCount)
  137. }
  138. func (s *GroupRepoSuite) TestGetByID_NotFound() {
  139. _, err := s.repo.GetByID(s.ctx, 999999)
  140. s.Require().Error(err, "expected error for non-existent ID")
  141. s.Require().ErrorIs(err, service.ErrGroupNotFound)
  142. }
  143. func (s *GroupRepoSuite) TestGetByIDLite_DoesNotUseAccountCount() {
  144. group := &service.Group{
  145. Name: "lite-group",
  146. Platform: service.PlatformAnthropic,
  147. RateMultiplier: 1.0,
  148. IsExclusive: false,
  149. Status: service.StatusActive,
  150. SubscriptionType: service.SubscriptionTypeStandard,
  151. }
  152. s.Require().NoError(s.repo.Create(s.ctx, group))
  153. spy := &forbidSQLExecutor{}
  154. repo := newGroupRepositoryWithSQL(s.tx.Client(), spy)
  155. got, err := repo.GetByIDLite(s.ctx, group.ID)
  156. s.Require().NoError(err)
  157. s.Require().Equal(group.ID, got.ID)
  158. s.Require().False(spy.called, "expected no direct sql executor usage")
  159. }
  160. func (s *GroupRepoSuite) TestUpdate() {
  161. group := &service.Group{
  162. Name: "original",
  163. Platform: service.PlatformAnthropic,
  164. RateMultiplier: 1.0,
  165. IsExclusive: false,
  166. Status: service.StatusActive,
  167. SubscriptionType: service.SubscriptionTypeStandard,
  168. }
  169. s.Require().NoError(s.repo.Create(s.ctx, group))
  170. group.Name = "updated"
  171. err := s.repo.Update(s.ctx, group)
  172. s.Require().NoError(err, "Update")
  173. got, err := s.repo.GetByID(s.ctx, group.ID)
  174. s.Require().NoError(err, "GetByID after update")
  175. s.Require().Equal("updated", got.Name)
  176. }
  177. func (s *GroupRepoSuite) TestGetByID_PreservesMessagesDispatchModelConfig() {
  178. group := &service.Group{
  179. Name: "openai-dispatch",
  180. Platform: service.PlatformOpenAI,
  181. RateMultiplier: 1.0,
  182. IsExclusive: false,
  183. Status: service.StatusActive,
  184. SubscriptionType: service.SubscriptionTypeStandard,
  185. AllowMessagesDispatch: true,
  186. DefaultMappedModel: "gpt-5.4",
  187. MessagesDispatchModelConfig: service.OpenAIMessagesDispatchModelConfig{
  188. OpusMappedModel: "gpt-5.4",
  189. SonnetMappedModel: "gpt-5.3-codex",
  190. HaikuMappedModel: "gpt-5.4-mini",
  191. ExactModelMappings: map[string]string{
  192. "claude-sonnet-4.5": "gpt-5.4-nano",
  193. },
  194. },
  195. }
  196. s.Require().NoError(s.repo.Create(s.ctx, group))
  197. got, err := s.repo.GetByID(s.ctx, group.ID)
  198. s.Require().NoError(err)
  199. s.Require().Equal(group.MessagesDispatchModelConfig, got.MessagesDispatchModelConfig)
  200. }
  201. func (s *GroupRepoSuite) TestDelete() {
  202. group := &service.Group{
  203. Name: "to-delete",
  204. Platform: service.PlatformAnthropic,
  205. RateMultiplier: 1.0,
  206. IsExclusive: false,
  207. Status: service.StatusActive,
  208. SubscriptionType: service.SubscriptionTypeStandard,
  209. }
  210. s.Require().NoError(s.repo.Create(s.ctx, group))
  211. err := s.repo.Delete(s.ctx, group.ID)
  212. s.Require().NoError(err, "Delete")
  213. _, err = s.repo.GetByID(s.ctx, group.ID)
  214. s.Require().Error(err, "expected error after delete")
  215. s.Require().ErrorIs(err, service.ErrGroupNotFound)
  216. }
  217. func (s *GroupRepoSuite) TestDeleteCascadeIfEmptyRejectsGroupWithNonDeletedAccount() {
  218. group := &service.Group{Name: "guarded-non-empty", Platform: service.PlatformAnthropic, RateMultiplier: 1, Status: service.StatusActive, SubscriptionType: service.SubscriptionTypeStandard}
  219. s.Require().NoError(s.repo.Create(s.ctx, group))
  220. var accountID int64
  221. s.Require().NoError(scanSingleRow(s.ctx, s.tx,
  222. "INSERT INTO accounts (name, platform, type) VALUES ($1, $2, $3) RETURNING id",
  223. []any{"guarded-account", service.PlatformAnthropic, service.AccountTypeOAuth}, &accountID))
  224. _, err := s.tx.ExecContext(s.ctx, "INSERT INTO account_groups (account_id, group_id, priority, created_at) VALUES ($1, $2, 1, NOW())", accountID, group.ID)
  225. s.Require().NoError(err)
  226. _, err = s.repo.DeleteCascadeIfEmpty(s.ctx, group.ID)
  227. s.Require().ErrorIs(err, service.ErrGroupNotEmpty)
  228. _, err = s.repo.GetByID(s.ctx, group.ID)
  229. s.Require().NoError(err)
  230. var bindings int
  231. s.Require().NoError(scanSingleRow(s.ctx, s.tx, "SELECT COUNT(*) FROM account_groups WHERE group_id = $1", []any{group.ID}, &bindings))
  232. s.Require().Equal(1, bindings)
  233. }
  234. func (s *GroupRepoSuite) TestDeleteCascadeIfEmptyDeletesEmptyGroup() {
  235. group := &service.Group{Name: "guarded-empty", Platform: service.PlatformAnthropic, RateMultiplier: 1, Status: service.StatusActive, SubscriptionType: service.SubscriptionTypeStandard}
  236. s.Require().NoError(s.repo.Create(s.ctx, group))
  237. _, err := s.repo.DeleteCascadeIfEmpty(s.ctx, group.ID)
  238. s.Require().NoError(err)
  239. _, err = s.repo.GetByID(s.ctx, group.ID)
  240. s.Require().ErrorIs(err, service.ErrGroupNotFound)
  241. }
  242. func (s *GroupRepoSuite) TestDeleteCascadeIfEmptyIgnoresBindingsToSoftDeletedAccounts() {
  243. group := &service.Group{Name: "guarded-soft-deleted-account", Platform: service.PlatformAnthropic, RateMultiplier: 1, Status: service.StatusActive, SubscriptionType: service.SubscriptionTypeStandard}
  244. s.Require().NoError(s.repo.Create(s.ctx, group))
  245. var accountID int64
  246. s.Require().NoError(scanSingleRow(s.ctx, s.tx,
  247. "INSERT INTO accounts (name, platform, type, deleted_at) VALUES ($1, $2, $3, NOW()) RETURNING id",
  248. []any{"guarded-deleted-account", service.PlatformAnthropic, service.AccountTypeOAuth}, &accountID))
  249. _, err := s.tx.ExecContext(s.ctx, "INSERT INTO account_groups (account_id, group_id, priority, created_at) VALUES ($1, $2, 1, NOW())", accountID, group.ID)
  250. s.Require().NoError(err)
  251. _, err = s.repo.DeleteCascadeIfEmpty(s.ctx, group.ID)
  252. s.Require().NoError(err)
  253. _, err = s.repo.GetByID(s.ctx, group.ID)
  254. s.Require().ErrorIs(err, service.ErrGroupNotFound)
  255. }
  256. func TestBindAccountsToGroupWaitingBehindGuardedDeleteCannotCommit(t *testing.T) {
  257. ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
  258. defer cancel()
  259. var groupID, accountID int64
  260. require.NoError(t, scanSingleRow(ctx, integrationDB,
  261. "INSERT INTO groups (name, platform, rate_multiplier, status, subscription_type) VALUES ($1, $2, 1, $3, $4) RETURNING id",
  262. []any{"guarded-delete-bind-race", service.PlatformAnthropic, service.StatusActive, service.SubscriptionTypeStandard}, &groupID))
  263. require.NoError(t, scanSingleRow(ctx, integrationDB,
  264. "INSERT INTO accounts (name, platform, type) VALUES ($1, $2, $3) RETURNING id",
  265. []any{"guarded-delete-bind-race-account", service.PlatformAnthropic, service.AccountTypeOAuth}, &accountID))
  266. t.Cleanup(func() {
  267. _, _ = integrationDB.ExecContext(context.Background(), "DELETE FROM account_groups WHERE group_id = $1 OR account_id = $2", groupID, accountID)
  268. _, _ = integrationDB.ExecContext(context.Background(), "DELETE FROM accounts WHERE id = $1", accountID)
  269. _, _ = integrationDB.ExecContext(context.Background(), "DELETE FROM groups WHERE id = $1", groupID)
  270. })
  271. deleteTx, err := integrationEntClient.Tx(ctx)
  272. require.NoError(t, err)
  273. defer func() { _ = deleteTx.Rollback() }()
  274. rows, err := deleteTx.Client().QueryContext(ctx, "SELECT id FROM groups WHERE id = $1 FOR UPDATE", groupID)
  275. require.NoError(t, err)
  276. require.True(t, rows.Next())
  277. require.NoError(t, rows.Close())
  278. bindDone := make(chan error, 1)
  279. go func() {
  280. bindDone <- newGroupRepositoryWithSQL(integrationEntClient, integrationDB).
  281. BindAccountsToGroup(ctx, groupID, []int64{accountID})
  282. }()
  283. require.Eventually(t, func() bool {
  284. var waiting bool
  285. err := scanSingleRow(ctx, integrationDB, `SELECT EXISTS (
  286. SELECT 1 FROM pg_stat_activity
  287. WHERE query LIKE '/* account_group_live_group_lock */%'
  288. AND wait_event_type = 'Lock'
  289. )`, nil, &waiting)
  290. return err == nil && waiting
  291. }, 5*time.Second, 10*time.Millisecond, "binder did not wait on the guarded deletion lock")
  292. deleteRepo := newGroupRepositoryWithSQL(deleteTx.Client(), deleteTx)
  293. _, err = deleteRepo.DeleteCascadeIfEmpty(ctx, groupID)
  294. require.NoError(t, err)
  295. require.NoError(t, deleteTx.Commit())
  296. require.ErrorIs(t, <-bindDone, service.ErrGroupNotFound)
  297. var bindings int
  298. require.NoError(t, scanSingleRow(ctx, integrationDB, "SELECT COUNT(*) FROM account_groups WHERE group_id = $1", []any{groupID}, &bindings))
  299. require.Zero(t, bindings)
  300. }
  301. // --- List / ListWithFilters ---
  302. func (s *GroupRepoSuite) TestList() {
  303. baseGroups, basePage, err := s.repo.List(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10})
  304. s.Require().NoError(err, "List base")
  305. s.Require().NoError(s.repo.Create(s.ctx, &service.Group{
  306. Name: "g1",
  307. Platform: service.PlatformAnthropic,
  308. RateMultiplier: 1.0,
  309. IsExclusive: false,
  310. Status: service.StatusActive,
  311. SubscriptionType: service.SubscriptionTypeStandard,
  312. }))
  313. s.Require().NoError(s.repo.Create(s.ctx, &service.Group{
  314. Name: "g2",
  315. Platform: service.PlatformAnthropic,
  316. RateMultiplier: 1.0,
  317. IsExclusive: false,
  318. Status: service.StatusActive,
  319. SubscriptionType: service.SubscriptionTypeStandard,
  320. }))
  321. groups, page, err := s.repo.List(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10})
  322. s.Require().NoError(err, "List")
  323. s.Require().Len(groups, len(baseGroups)+2)
  324. s.Require().Equal(basePage.Total+2, page.Total)
  325. }
  326. func (s *GroupRepoSuite) TestListWithFilters_Platform() {
  327. baseGroups, _, err := s.repo.ListWithFilters(
  328. s.ctx,
  329. pagination.PaginationParams{Page: 1, PageSize: 10},
  330. service.PlatformOpenAI,
  331. "",
  332. "",
  333. nil,
  334. )
  335. s.Require().NoError(err, "ListWithFilters base")
  336. s.Require().NoError(s.repo.Create(s.ctx, &service.Group{
  337. Name: "g1",
  338. Platform: service.PlatformAnthropic,
  339. RateMultiplier: 1.0,
  340. IsExclusive: false,
  341. Status: service.StatusActive,
  342. SubscriptionType: service.SubscriptionTypeStandard,
  343. }))
  344. s.Require().NoError(s.repo.Create(s.ctx, &service.Group{
  345. Name: "g2",
  346. Platform: service.PlatformOpenAI,
  347. RateMultiplier: 1.0,
  348. IsExclusive: false,
  349. Status: service.StatusActive,
  350. SubscriptionType: service.SubscriptionTypeStandard,
  351. }))
  352. groups, _, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, service.PlatformOpenAI, "", "", nil)
  353. s.Require().NoError(err)
  354. s.Require().Len(groups, len(baseGroups)+1)
  355. // Verify all groups are OpenAI platform
  356. for _, g := range groups {
  357. s.Require().Equal(service.PlatformOpenAI, g.Platform)
  358. }
  359. }
  360. func (s *GroupRepoSuite) TestListWithFilters_Status() {
  361. s.Require().NoError(s.repo.Create(s.ctx, &service.Group{
  362. Name: "g1",
  363. Platform: service.PlatformAnthropic,
  364. RateMultiplier: 1.0,
  365. IsExclusive: false,
  366. Status: service.StatusActive,
  367. SubscriptionType: service.SubscriptionTypeStandard,
  368. }))
  369. s.Require().NoError(s.repo.Create(s.ctx, &service.Group{
  370. Name: "g2",
  371. Platform: service.PlatformAnthropic,
  372. RateMultiplier: 1.0,
  373. IsExclusive: false,
  374. Status: service.StatusDisabled,
  375. SubscriptionType: service.SubscriptionTypeStandard,
  376. }))
  377. groups, _, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, "", service.StatusDisabled, "", nil)
  378. s.Require().NoError(err)
  379. s.Require().Len(groups, 1)
  380. s.Require().Equal(service.StatusDisabled, groups[0].Status)
  381. }
  382. func (s *GroupRepoSuite) TestListWithFilters_IsExclusive() {
  383. s.Require().NoError(s.repo.Create(s.ctx, &service.Group{
  384. Name: "g1",
  385. Platform: service.PlatformAnthropic,
  386. RateMultiplier: 1.0,
  387. IsExclusive: false,
  388. Status: service.StatusActive,
  389. SubscriptionType: service.SubscriptionTypeStandard,
  390. }))
  391. s.Require().NoError(s.repo.Create(s.ctx, &service.Group{
  392. Name: "g2",
  393. Platform: service.PlatformAnthropic,
  394. RateMultiplier: 1.0,
  395. IsExclusive: true,
  396. Status: service.StatusActive,
  397. SubscriptionType: service.SubscriptionTypeStandard,
  398. }))
  399. isExclusive := true
  400. groups, _, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, "", "", "", &isExclusive)
  401. s.Require().NoError(err)
  402. s.Require().Len(groups, 1)
  403. s.Require().True(groups[0].IsExclusive)
  404. }
  405. func (s *GroupRepoSuite) TestListWithFilters_Search() {
  406. newRepo := func() (*groupRepository, context.Context) {
  407. tx := testEntTx(s.T())
  408. return newGroupRepositoryWithSQL(tx.Client(), tx), context.Background()
  409. }
  410. containsID := func(groups []service.Group, id int64) bool {
  411. for i := range groups {
  412. if groups[i].ID == id {
  413. return true
  414. }
  415. }
  416. return false
  417. }
  418. mustCreate := func(repo *groupRepository, ctx context.Context, g *service.Group) *service.Group {
  419. s.Require().NoError(repo.Create(ctx, g))
  420. s.Require().NotZero(g.ID)
  421. return g
  422. }
  423. newGroup := func(name string) *service.Group {
  424. return &service.Group{
  425. Name: name,
  426. Platform: service.PlatformAnthropic,
  427. RateMultiplier: 1.0,
  428. IsExclusive: false,
  429. Status: service.StatusActive,
  430. SubscriptionType: service.SubscriptionTypeStandard,
  431. }
  432. }
  433. s.Run("search_name_should_match", func() {
  434. repo, ctx := newRepo()
  435. target := mustCreate(repo, ctx, newGroup("it-group-search-name-target"))
  436. other := mustCreate(repo, ctx, newGroup("it-group-search-name-other"))
  437. groups, _, err := repo.ListWithFilters(ctx, pagination.PaginationParams{Page: 1, PageSize: 50}, "", "", "name-target", nil)
  438. s.Require().NoError(err)
  439. s.Require().True(containsID(groups, target.ID), "expected target group to match by name")
  440. s.Require().False(containsID(groups, other.ID), "expected other group to be filtered out")
  441. })
  442. s.Run("search_description_should_match", func() {
  443. repo, ctx := newRepo()
  444. target := newGroup("it-group-search-desc-target")
  445. target.Description = "something about desc-needle in here"
  446. target = mustCreate(repo, ctx, target)
  447. other := newGroup("it-group-search-desc-other")
  448. other.Description = "nothing to see here"
  449. other = mustCreate(repo, ctx, other)
  450. groups, _, err := repo.ListWithFilters(ctx, pagination.PaginationParams{Page: 1, PageSize: 50}, "", "", "desc-needle", nil)
  451. s.Require().NoError(err)
  452. s.Require().True(containsID(groups, target.ID), "expected target group to match by description")
  453. s.Require().False(containsID(groups, other.ID), "expected other group to be filtered out")
  454. })
  455. s.Run("search_nonexistent_should_return_empty", func() {
  456. repo, ctx := newRepo()
  457. _ = mustCreate(repo, ctx, newGroup("it-group-search-nonexistent-baseline"))
  458. search := s.T().Name() + "__no_such_group__"
  459. groups, _, err := repo.ListWithFilters(ctx, pagination.PaginationParams{Page: 1, PageSize: 50}, "", "", search, nil)
  460. s.Require().NoError(err)
  461. s.Require().Empty(groups)
  462. })
  463. s.Run("search_should_be_case_insensitive", func() {
  464. repo, ctx := newRepo()
  465. target := mustCreate(repo, ctx, newGroup("MiXeDCaSe-Needle"))
  466. other := mustCreate(repo, ctx, newGroup("it-group-search-case-other"))
  467. groups, _, err := repo.ListWithFilters(ctx, pagination.PaginationParams{Page: 1, PageSize: 50}, "", "", "mixedcase-needle", nil)
  468. s.Require().NoError(err)
  469. s.Require().True(containsID(groups, target.ID), "expected case-insensitive match")
  470. s.Require().False(containsID(groups, other.ID), "expected other group to be filtered out")
  471. })
  472. s.Run("search_should_escape_like_wildcards", func() {
  473. repo, ctx := newRepo()
  474. percentTarget := mustCreate(repo, ctx, newGroup("it-group-search-100%-target"))
  475. percentOther := mustCreate(repo, ctx, newGroup("it-group-search-100X-other"))
  476. groups, _, err := repo.ListWithFilters(ctx, pagination.PaginationParams{Page: 1, PageSize: 50}, "", "", "100%", nil)
  477. s.Require().NoError(err)
  478. s.Require().True(containsID(groups, percentTarget.ID), "expected literal %% match")
  479. s.Require().False(containsID(groups, percentOther.ID), "expected %% not to act as wildcard")
  480. underscoreTarget := mustCreate(repo, ctx, newGroup("it-group-search-ab_cd-target"))
  481. underscoreOther := mustCreate(repo, ctx, newGroup("it-group-search-abXcd-other"))
  482. groups, _, err = repo.ListWithFilters(ctx, pagination.PaginationParams{Page: 1, PageSize: 50}, "", "", "ab_cd", nil)
  483. s.Require().NoError(err)
  484. s.Require().True(containsID(groups, underscoreTarget.ID), "expected literal _ match")
  485. s.Require().False(containsID(groups, underscoreOther.ID), "expected _ not to act as wildcard")
  486. })
  487. }
  488. func (s *GroupRepoSuite) TestUpdateSortOrders_BatchCaseWhen() {
  489. g1 := &service.Group{
  490. Name: "sort-g1",
  491. Platform: service.PlatformAnthropic,
  492. RateMultiplier: 1.0,
  493. IsExclusive: false,
  494. Status: service.StatusActive,
  495. SubscriptionType: service.SubscriptionTypeStandard,
  496. }
  497. g2 := &service.Group{
  498. Name: "sort-g2",
  499. Platform: service.PlatformAnthropic,
  500. RateMultiplier: 1.0,
  501. IsExclusive: false,
  502. Status: service.StatusActive,
  503. SubscriptionType: service.SubscriptionTypeStandard,
  504. }
  505. g3 := &service.Group{
  506. Name: "sort-g3",
  507. Platform: service.PlatformAnthropic,
  508. RateMultiplier: 1.0,
  509. IsExclusive: false,
  510. Status: service.StatusActive,
  511. SubscriptionType: service.SubscriptionTypeStandard,
  512. }
  513. s.Require().NoError(s.repo.Create(s.ctx, g1))
  514. s.Require().NoError(s.repo.Create(s.ctx, g2))
  515. s.Require().NoError(s.repo.Create(s.ctx, g3))
  516. err := s.repo.UpdateSortOrders(s.ctx, []service.GroupSortOrderUpdate{
  517. {ID: g1.ID, SortOrder: 30},
  518. {ID: g2.ID, SortOrder: 10},
  519. {ID: g3.ID, SortOrder: 20},
  520. {ID: g2.ID, SortOrder: 15}, // 重复 ID 应以最后一次为准
  521. })
  522. s.Require().NoError(err)
  523. got1, err := s.repo.GetByID(s.ctx, g1.ID)
  524. s.Require().NoError(err)
  525. got2, err := s.repo.GetByID(s.ctx, g2.ID)
  526. s.Require().NoError(err)
  527. got3, err := s.repo.GetByID(s.ctx, g3.ID)
  528. s.Require().NoError(err)
  529. s.Require().Equal(30, got1.SortOrder)
  530. s.Require().Equal(15, got2.SortOrder)
  531. s.Require().Equal(20, got3.SortOrder)
  532. }
  533. func (s *GroupRepoSuite) TestUpdateSortOrders_MissingGroupNoPartialUpdate() {
  534. g1 := &service.Group{
  535. Name: "sort-no-partial",
  536. Platform: service.PlatformAnthropic,
  537. RateMultiplier: 1.0,
  538. IsExclusive: false,
  539. Status: service.StatusActive,
  540. SubscriptionType: service.SubscriptionTypeStandard,
  541. }
  542. s.Require().NoError(s.repo.Create(s.ctx, g1))
  543. before, err := s.repo.GetByID(s.ctx, g1.ID)
  544. s.Require().NoError(err)
  545. beforeSort := before.SortOrder
  546. err = s.repo.UpdateSortOrders(s.ctx, []service.GroupSortOrderUpdate{
  547. {ID: g1.ID, SortOrder: 99},
  548. {ID: 99999999, SortOrder: 1},
  549. })
  550. s.Require().Error(err)
  551. s.Require().ErrorIs(err, service.ErrGroupNotFound)
  552. after, err := s.repo.GetByID(s.ctx, g1.ID)
  553. s.Require().NoError(err)
  554. s.Require().Equal(beforeSort, after.SortOrder)
  555. }
  556. func (s *GroupRepoSuite) TestListWithFilters_AccountCount() {
  557. g1 := &service.Group{
  558. Name: "g1",
  559. Platform: service.PlatformAnthropic,
  560. RateMultiplier: 1.0,
  561. IsExclusive: false,
  562. Status: service.StatusActive,
  563. SubscriptionType: service.SubscriptionTypeStandard,
  564. }
  565. g2 := &service.Group{
  566. Name: "g2",
  567. Platform: service.PlatformAnthropic,
  568. RateMultiplier: 1.0,
  569. IsExclusive: true,
  570. Status: service.StatusActive,
  571. SubscriptionType: service.SubscriptionTypeStandard,
  572. }
  573. s.Require().NoError(s.repo.Create(s.ctx, g1))
  574. s.Require().NoError(s.repo.Create(s.ctx, g2))
  575. var accountID int64
  576. s.Require().NoError(scanSingleRow(
  577. s.ctx,
  578. s.tx,
  579. "INSERT INTO accounts (name, platform, type) VALUES ($1, $2, $3) RETURNING id",
  580. []any{"acc1", service.PlatformAnthropic, service.AccountTypeOAuth},
  581. &accountID,
  582. ))
  583. _, err := s.tx.ExecContext(s.ctx, "INSERT INTO account_groups (account_id, group_id, priority, created_at) VALUES ($1, $2, $3, NOW())", accountID, g1.ID, 1)
  584. s.Require().NoError(err)
  585. _, err = s.tx.ExecContext(s.ctx, "INSERT INTO account_groups (account_id, group_id, priority, created_at) VALUES ($1, $2, $3, NOW())", accountID, g2.ID, 1)
  586. s.Require().NoError(err)
  587. isExclusive := true
  588. groups, page, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, service.PlatformAnthropic, service.StatusActive, "", &isExclusive)
  589. s.Require().NoError(err, "ListWithFilters")
  590. s.Require().Equal(int64(1), page.Total)
  591. s.Require().Len(groups, 1)
  592. s.Require().Equal(g2.ID, groups[0].ID, "ListWithFilters returned wrong group")
  593. s.Require().Equal(int64(1), groups[0].AccountCount, "AccountCount mismatch")
  594. }
  595. // --- ListActive / ListActiveByPlatform ---
  596. func (s *GroupRepoSuite) TestListActive() {
  597. baseGroups, err := s.repo.ListActive(s.ctx)
  598. s.Require().NoError(err, "ListActive base")
  599. s.Require().NoError(s.repo.Create(s.ctx, &service.Group{
  600. Name: "active1",
  601. Platform: service.PlatformAnthropic,
  602. RateMultiplier: 1.0,
  603. IsExclusive: false,
  604. Status: service.StatusActive,
  605. SubscriptionType: service.SubscriptionTypeStandard,
  606. }))
  607. s.Require().NoError(s.repo.Create(s.ctx, &service.Group{
  608. Name: "inactive1",
  609. Platform: service.PlatformAnthropic,
  610. RateMultiplier: 1.0,
  611. IsExclusive: false,
  612. Status: service.StatusDisabled,
  613. SubscriptionType: service.SubscriptionTypeStandard,
  614. }))
  615. groups, err := s.repo.ListActive(s.ctx)
  616. s.Require().NoError(err, "ListActive")
  617. s.Require().Len(groups, len(baseGroups)+1)
  618. // Verify our test group is in the results
  619. var found bool
  620. for _, g := range groups {
  621. if g.Name == "active1" {
  622. found = true
  623. break
  624. }
  625. }
  626. s.Require().True(found, "active1 group should be in results")
  627. }
  628. func (s *GroupRepoSuite) TestListBindableWithFiltersAppliesFilterBeforePagination() {
  629. for _, g := range []*service.Group{
  630. {Name: "bindable-a", Platform: service.PlatformAnthropic, RateMultiplier: 1, Status: service.StatusActive, SubscriptionType: service.SubscriptionTypeStandard},
  631. {Name: "composite-hidden", Platform: service.PlatformComposite, RateMultiplier: 1, Status: service.StatusActive, SubscriptionType: service.SubscriptionTypeStandard},
  632. {Name: "bindable-b", Platform: service.PlatformOpenAI, RateMultiplier: 1, Status: service.StatusActive, SubscriptionType: service.SubscriptionTypeStandard},
  633. } {
  634. s.Require().NoError(s.repo.Create(s.ctx, g))
  635. }
  636. groups, page, err := s.repo.ListBindableWithFilters(s.ctx, pagination.PaginationParams{Page: 2, PageSize: 1, SortBy: "name", SortOrder: "asc"}, "", service.StatusActive, "bindable-", nil)
  637. s.Require().NoError(err)
  638. s.Require().Equal(int64(2), page.Total)
  639. s.Require().Len(groups, 1)
  640. s.Require().NotEqual(service.PlatformComposite, groups[0].Platform)
  641. }
  642. func (s *GroupRepoSuite) TestListActiveByPlatform() {
  643. s.Require().NoError(s.repo.Create(s.ctx, &service.Group{
  644. Name: "g1",
  645. Platform: service.PlatformAnthropic,
  646. RateMultiplier: 1.0,
  647. IsExclusive: false,
  648. Status: service.StatusActive,
  649. SubscriptionType: service.SubscriptionTypeStandard,
  650. }))
  651. s.Require().NoError(s.repo.Create(s.ctx, &service.Group{
  652. Name: "g2",
  653. Platform: service.PlatformOpenAI,
  654. RateMultiplier: 1.0,
  655. IsExclusive: false,
  656. Status: service.StatusActive,
  657. SubscriptionType: service.SubscriptionTypeStandard,
  658. }))
  659. s.Require().NoError(s.repo.Create(s.ctx, &service.Group{
  660. Name: "g3",
  661. Platform: service.PlatformAnthropic,
  662. RateMultiplier: 1.0,
  663. IsExclusive: false,
  664. Status: service.StatusDisabled,
  665. SubscriptionType: service.SubscriptionTypeStandard,
  666. }))
  667. groups, err := s.repo.ListActiveByPlatform(s.ctx, service.PlatformAnthropic)
  668. s.Require().NoError(err, "ListActiveByPlatform")
  669. // 1 default anthropic group + 1 test active anthropic group = 2 total
  670. s.Require().Len(groups, 2)
  671. // Verify our test group is in the results
  672. var found bool
  673. for _, g := range groups {
  674. if g.Name == "g1" {
  675. found = true
  676. break
  677. }
  678. }
  679. s.Require().True(found, "g1 group should be in results")
  680. }
  681. // --- ExistsByName ---
  682. func (s *GroupRepoSuite) TestExistsByName() {
  683. s.Require().NoError(s.repo.Create(s.ctx, &service.Group{
  684. Name: "existing-group",
  685. Platform: service.PlatformAnthropic,
  686. RateMultiplier: 1.0,
  687. IsExclusive: false,
  688. Status: service.StatusActive,
  689. SubscriptionType: service.SubscriptionTypeStandard,
  690. }))
  691. exists, err := s.repo.ExistsByName(s.ctx, "existing-group")
  692. s.Require().NoError(err, "ExistsByName")
  693. s.Require().True(exists)
  694. notExists, err := s.repo.ExistsByName(s.ctx, "non-existing")
  695. s.Require().NoError(err)
  696. s.Require().False(notExists)
  697. }
  698. // --- GetAccountCount ---
  699. func (s *GroupRepoSuite) TestGetAccountCount() {
  700. group := &service.Group{
  701. Name: "g-count",
  702. Platform: service.PlatformAnthropic,
  703. RateMultiplier: 1.0,
  704. IsExclusive: false,
  705. Status: service.StatusActive,
  706. SubscriptionType: service.SubscriptionTypeStandard,
  707. }
  708. s.Require().NoError(s.repo.Create(s.ctx, group))
  709. var a1 int64
  710. s.Require().NoError(scanSingleRow(
  711. s.ctx,
  712. s.tx,
  713. "INSERT INTO accounts (name, platform, type) VALUES ($1, $2, $3) RETURNING id",
  714. []any{"a1", service.PlatformAnthropic, service.AccountTypeOAuth},
  715. &a1,
  716. ))
  717. var a2 int64
  718. s.Require().NoError(scanSingleRow(
  719. s.ctx,
  720. s.tx,
  721. "INSERT INTO accounts (name, platform, type) VALUES ($1, $2, $3) RETURNING id",
  722. []any{"a2", service.PlatformAnthropic, service.AccountTypeOAuth},
  723. &a2,
  724. ))
  725. _, err := s.tx.ExecContext(s.ctx, "INSERT INTO account_groups (account_id, group_id, priority, created_at) VALUES ($1, $2, $3, NOW())", a1, group.ID, 1)
  726. s.Require().NoError(err)
  727. _, err = s.tx.ExecContext(s.ctx, "INSERT INTO account_groups (account_id, group_id, priority, created_at) VALUES ($1, $2, $3, NOW())", a2, group.ID, 2)
  728. s.Require().NoError(err)
  729. count, _, err := s.repo.GetAccountCount(s.ctx, group.ID)
  730. s.Require().NoError(err, "GetAccountCount")
  731. s.Require().Equal(int64(2), count)
  732. }
  733. func (s *GroupRepoSuite) TestGetAccountCount_Empty() {
  734. group := &service.Group{
  735. Name: "g-empty",
  736. Platform: service.PlatformAnthropic,
  737. RateMultiplier: 1.0,
  738. IsExclusive: false,
  739. Status: service.StatusActive,
  740. SubscriptionType: service.SubscriptionTypeStandard,
  741. }
  742. s.Require().NoError(s.repo.Create(s.ctx, group))
  743. count, _, err := s.repo.GetAccountCount(s.ctx, group.ID)
  744. s.Require().NoError(err)
  745. s.Require().Zero(count)
  746. }
  747. // TestListWithFilters_ActiveAccountCount_LessThanTotal 验证 ActiveAccountCount 正确区分可用与不可用账号。
  748. // 当分组内存在 disabled 或 schedulable=false 的账号时,ActiveAccountCount 必须小于 AccountCount,
  749. // 且与 GetAccountCount 返回的 active 值一致。
  750. func (s *GroupRepoSuite) TestListWithFilters_ActiveAccountCount_LessThanTotal() {
  751. g := &service.Group{
  752. Name: "g-mixed-status",
  753. Platform: service.PlatformAnthropic,
  754. RateMultiplier: 1.0,
  755. IsExclusive: false,
  756. Status: service.StatusActive,
  757. SubscriptionType: service.SubscriptionTypeStandard,
  758. }
  759. s.Require().NoError(s.repo.Create(s.ctx, g))
  760. insertAccount := func(name, status string, schedulable bool) int64 {
  761. var id int64
  762. s.Require().NoError(scanSingleRow(
  763. s.ctx, s.tx,
  764. "INSERT INTO accounts (name, platform, type, status, schedulable) VALUES ($1, $2, $3, $4, $5) RETURNING id",
  765. []any{name, service.PlatformAnthropic, service.AccountTypeOAuth, status, schedulable},
  766. &id,
  767. ))
  768. return id
  769. }
  770. link := func(accountID int64, priority int) {
  771. _, err := s.tx.ExecContext(s.ctx,
  772. "INSERT INTO account_groups (account_id, group_id, priority, created_at) VALUES ($1, $2, $3, NOW())",
  773. accountID, g.ID, priority)
  774. s.Require().NoError(err)
  775. }
  776. // account 1: active + schedulable → counts toward both total and active
  777. link(insertAccount("acc-active-sched", service.StatusActive, true), 1)
  778. // account 2: disabled → counts toward total only
  779. link(insertAccount("acc-disabled", service.StatusDisabled, true), 2)
  780. // account 3: active + not schedulable → counts toward total only
  781. link(insertAccount("acc-unschedulable", service.StatusActive, false), 3)
  782. // --- ListWithFilters path ---
  783. isExclusive := false
  784. groups, _, err := s.repo.ListWithFilters(s.ctx,
  785. pagination.PaginationParams{Page: 1, PageSize: 100},
  786. service.PlatformAnthropic, service.StatusActive, "", &isExclusive)
  787. s.Require().NoError(err)
  788. var found *service.Group
  789. for i := range groups {
  790. if groups[i].ID == g.ID {
  791. found = &groups[i]
  792. break
  793. }
  794. }
  795. s.Require().NotNil(found, "created group must appear in ListWithFilters result")
  796. s.Assert().Equal(int64(3), found.AccountCount, "AccountCount must count all 3 accounts")
  797. s.Assert().Equal(int64(1), found.ActiveAccountCount, "ActiveAccountCount must count only the active+schedulable account")
  798. // --- GetAccountCount must return identical values ---
  799. total, active, err := s.repo.GetAccountCount(s.ctx, g.ID)
  800. s.Require().NoError(err)
  801. s.Assert().Equal(found.AccountCount, total, "GetAccountCount total must match ListWithFilters AccountCount")
  802. s.Assert().Equal(found.ActiveAccountCount, active, "GetAccountCount active must match ListWithFilters ActiveAccountCount")
  803. }
  804. // TestListWithFilters_RateLimitedAccountCount 验证临时受限账号不会计入可用账号数。
  805. // rate_limit / overload / temp_unschedulable 都会让账号退出当前调度池,
  806. // 因此 ActiveAccountCount 必须与真实调度查询口径一致。
  807. func (s *GroupRepoSuite) TestListWithFilters_RateLimitedAccountCount() {
  808. g := &service.Group{
  809. Name: "g-rate-limited",
  810. Platform: service.PlatformAnthropic,
  811. RateMultiplier: 1.0,
  812. IsExclusive: false,
  813. Status: service.StatusActive,
  814. SubscriptionType: service.SubscriptionTypeStandard,
  815. }
  816. s.Require().NoError(s.repo.Create(s.ctx, g))
  817. var normalID int64
  818. s.Require().NoError(scanSingleRow(s.ctx, s.tx,
  819. "INSERT INTO accounts (name, platform, type) VALUES ($1, $2, $3) RETURNING id",
  820. []any{"acc-normal", service.PlatformAnthropic, service.AccountTypeOAuth},
  821. &normalID))
  822. var rateLimitedID int64
  823. s.Require().NoError(scanSingleRow(s.ctx, s.tx,
  824. "INSERT INTO accounts (name, platform, type, rate_limit_reset_at) VALUES ($1, $2, $3, NOW() + INTERVAL '1 hour') RETURNING id",
  825. []any{"acc-rate-limited", service.PlatformAnthropic, service.AccountTypeOAuth},
  826. &rateLimitedID))
  827. var overloadedID int64
  828. s.Require().NoError(scanSingleRow(s.ctx, s.tx,
  829. "INSERT INTO accounts (name, platform, type, overload_until) VALUES ($1, $2, $3, NOW() + INTERVAL '1 hour') RETURNING id",
  830. []any{"acc-overloaded", service.PlatformAnthropic, service.AccountTypeOAuth},
  831. &overloadedID))
  832. var tempUnschedulableID int64
  833. s.Require().NoError(scanSingleRow(s.ctx, s.tx,
  834. "INSERT INTO accounts (name, platform, type, temp_unschedulable_until) VALUES ($1, $2, $3, NOW() + INTERVAL '1 hour') RETURNING id",
  835. []any{"acc-temp-unschedulable", service.PlatformAnthropic, service.AccountTypeOAuth},
  836. &tempUnschedulableID))
  837. var expiredID int64
  838. s.Require().NoError(scanSingleRow(s.ctx, s.tx,
  839. "INSERT INTO accounts (name, platform, type, expires_at, auto_pause_on_expired) VALUES ($1, $2, $3, NOW() - INTERVAL '1 hour', TRUE) RETURNING id",
  840. []any{"acc-expired", service.PlatformAnthropic, service.AccountTypeOAuth},
  841. &expiredID))
  842. _, err := s.tx.ExecContext(s.ctx,
  843. "INSERT INTO account_groups (account_id, group_id, priority, created_at) VALUES ($1, $2, $3, NOW())",
  844. normalID, g.ID, 1)
  845. s.Require().NoError(err)
  846. _, err = s.tx.ExecContext(s.ctx,
  847. "INSERT INTO account_groups (account_id, group_id, priority, created_at) VALUES ($1, $2, $3, NOW())",
  848. rateLimitedID, g.ID, 2)
  849. s.Require().NoError(err)
  850. _, err = s.tx.ExecContext(s.ctx,
  851. "INSERT INTO account_groups (account_id, group_id, priority, created_at) VALUES ($1, $2, $3, NOW())",
  852. overloadedID, g.ID, 3)
  853. s.Require().NoError(err)
  854. _, err = s.tx.ExecContext(s.ctx,
  855. "INSERT INTO account_groups (account_id, group_id, priority, created_at) VALUES ($1, $2, $3, NOW())",
  856. tempUnschedulableID, g.ID, 4)
  857. s.Require().NoError(err)
  858. _, err = s.tx.ExecContext(s.ctx,
  859. "INSERT INTO account_groups (account_id, group_id, priority, created_at) VALUES ($1, $2, $3, NOW())",
  860. expiredID, g.ID, 5)
  861. s.Require().NoError(err)
  862. isExclusive := false
  863. groups, _, err := s.repo.ListWithFilters(s.ctx,
  864. pagination.PaginationParams{Page: 1, PageSize: 100},
  865. service.PlatformAnthropic, service.StatusActive, "", &isExclusive)
  866. s.Require().NoError(err)
  867. var found *service.Group
  868. for i := range groups {
  869. if groups[i].ID == g.ID {
  870. found = &groups[i]
  871. break
  872. }
  873. }
  874. s.Require().NotNil(found, "created group must appear in ListWithFilters result")
  875. s.Assert().Equal(int64(5), found.AccountCount, "AccountCount must include all linked accounts")
  876. s.Assert().Equal(int64(1), found.ActiveAccountCount, "ActiveAccountCount must include only currently schedulable accounts")
  877. s.Assert().Equal(int64(3), found.RateLimitedAccountCount, "RateLimitedAccountCount must include temporarily limited accounts")
  878. total, active, err := s.repo.GetAccountCount(s.ctx, g.ID)
  879. s.Require().NoError(err)
  880. s.Assert().Equal(found.AccountCount, total, "GetAccountCount total must match ListWithFilters AccountCount")
  881. s.Assert().Equal(found.ActiveAccountCount, active, "GetAccountCount active must match ListWithFilters ActiveAccountCount")
  882. detail, err := s.repo.GetByID(s.ctx, g.ID)
  883. s.Require().NoError(err)
  884. s.Assert().Equal(found.AccountCount, detail.AccountCount, "GetByID AccountCount must match ListWithFilters")
  885. s.Assert().Equal(found.ActiveAccountCount, detail.ActiveAccountCount, "GetByID ActiveAccountCount must match ListWithFilters")
  886. s.Assert().Equal(found.RateLimitedAccountCount, detail.RateLimitedAccountCount, "GetByID RateLimitedAccountCount must match ListWithFilters")
  887. }
  888. // --- DeleteAccountGroupsByGroupID ---
  889. func (s *GroupRepoSuite) TestDeleteAccountGroupsByGroupID() {
  890. g := &service.Group{
  891. Name: "g-del",
  892. Platform: service.PlatformAnthropic,
  893. RateMultiplier: 1.0,
  894. IsExclusive: false,
  895. Status: service.StatusActive,
  896. SubscriptionType: service.SubscriptionTypeStandard,
  897. }
  898. s.Require().NoError(s.repo.Create(s.ctx, g))
  899. var accountID int64
  900. s.Require().NoError(scanSingleRow(
  901. s.ctx,
  902. s.tx,
  903. "INSERT INTO accounts (name, platform, type) VALUES ($1, $2, $3) RETURNING id",
  904. []any{"acc-del", service.PlatformAnthropic, service.AccountTypeOAuth},
  905. &accountID,
  906. ))
  907. _, err := s.tx.ExecContext(s.ctx, "INSERT INTO account_groups (account_id, group_id, priority, created_at) VALUES ($1, $2, $3, NOW())", accountID, g.ID, 1)
  908. s.Require().NoError(err)
  909. affected, err := s.repo.DeleteAccountGroupsByGroupID(s.ctx, g.ID)
  910. s.Require().NoError(err, "DeleteAccountGroupsByGroupID")
  911. s.Require().Equal(int64(1), affected, "expected 1 affected row")
  912. count, _, err := s.repo.GetAccountCount(s.ctx, g.ID)
  913. s.Require().NoError(err, "GetAccountCount")
  914. s.Require().Equal(int64(0), count, "expected 0 account groups")
  915. }
  916. func (s *GroupRepoSuite) TestDeleteAccountGroupsByGroupID_MultipleAccounts() {
  917. g := &service.Group{
  918. Name: "g-multi",
  919. Platform: service.PlatformAnthropic,
  920. RateMultiplier: 1.0,
  921. IsExclusive: false,
  922. Status: service.StatusActive,
  923. SubscriptionType: service.SubscriptionTypeStandard,
  924. }
  925. s.Require().NoError(s.repo.Create(s.ctx, g))
  926. insertAccount := func(name string) int64 {
  927. var id int64
  928. s.Require().NoError(scanSingleRow(
  929. s.ctx,
  930. s.tx,
  931. "INSERT INTO accounts (name, platform, type) VALUES ($1, $2, $3) RETURNING id",
  932. []any{name, service.PlatformAnthropic, service.AccountTypeOAuth},
  933. &id,
  934. ))
  935. return id
  936. }
  937. a1 := insertAccount("a1")
  938. a2 := insertAccount("a2")
  939. a3 := insertAccount("a3")
  940. _, err := s.tx.ExecContext(s.ctx, "INSERT INTO account_groups (account_id, group_id, priority, created_at) VALUES ($1, $2, $3, NOW())", a1, g.ID, 1)
  941. s.Require().NoError(err)
  942. _, err = s.tx.ExecContext(s.ctx, "INSERT INTO account_groups (account_id, group_id, priority, created_at) VALUES ($1, $2, $3, NOW())", a2, g.ID, 2)
  943. s.Require().NoError(err)
  944. _, err = s.tx.ExecContext(s.ctx, "INSERT INTO account_groups (account_id, group_id, priority, created_at) VALUES ($1, $2, $3, NOW())", a3, g.ID, 3)
  945. s.Require().NoError(err)
  946. affected, err := s.repo.DeleteAccountGroupsByGroupID(s.ctx, g.ID)
  947. s.Require().NoError(err)
  948. s.Require().Equal(int64(3), affected)
  949. count, _, _ := s.repo.GetAccountCount(s.ctx, g.ID)
  950. s.Require().Zero(count)
  951. }
  952. // --- 软删除过滤测试 ---
  953. func (s *GroupRepoSuite) TestDelete_SoftDelete_NotVisibleInList() {
  954. group := &service.Group{
  955. Name: "to-soft-delete",
  956. Platform: service.PlatformAnthropic,
  957. RateMultiplier: 1.0,
  958. IsExclusive: false,
  959. Status: service.StatusActive,
  960. SubscriptionType: service.SubscriptionTypeStandard,
  961. }
  962. s.Require().NoError(s.repo.Create(s.ctx, group))
  963. // 获取删除前的列表数量
  964. listBefore, _, err := s.repo.List(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 100})
  965. s.Require().NoError(err)
  966. beforeCount := len(listBefore)
  967. // 软删除
  968. err = s.repo.Delete(s.ctx, group.ID)
  969. s.Require().NoError(err, "Delete (soft delete)")
  970. // 验证列表中不再包含软删除的 group
  971. listAfter, _, err := s.repo.List(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 100})
  972. s.Require().NoError(err)
  973. s.Require().Len(listAfter, beforeCount-1, "soft deleted group should not appear in list")
  974. // 验证 GetByID 也无法找到
  975. _, err = s.repo.GetByID(s.ctx, group.ID)
  976. s.Require().Error(err)
  977. s.Require().ErrorIs(err, service.ErrGroupNotFound)
  978. }
  979. func (s *GroupRepoSuite) TestDelete_SoftDeletedGroup_lockForUpdate() {
  980. group := &service.Group{
  981. Name: "lock-soft-delete",
  982. Platform: service.PlatformAnthropic,
  983. RateMultiplier: 1.0,
  984. IsExclusive: false,
  985. Status: service.StatusActive,
  986. SubscriptionType: service.SubscriptionTypeStandard,
  987. }
  988. s.Require().NoError(s.repo.Create(s.ctx, group))
  989. // 软删除
  990. err := s.repo.Delete(s.ctx, group.ID)
  991. s.Require().NoError(err)
  992. // 验证软删除的 group 在 GetByID 时返回 ErrGroupNotFound
  993. // 这证明 lockForUpdate 的 deleted_at IS NULL 过滤正在工作
  994. _, err = s.repo.GetByID(s.ctx, group.ID)
  995. s.Require().Error(err, "should fail to get soft-deleted group")
  996. s.Require().ErrorIs(err, service.ErrGroupNotFound)
  997. }