diff --git a/backend/internal/repository/group_repo_integration_test.go b/backend/internal/repository/group_repo_integration_test.go index f91dae43..c98d8861 100644 --- a/backend/internal/repository/group_repo_integration_test.go +++ b/backend/internal/repository/group_repo_integration_test.go @@ -651,6 +651,124 @@ func (s *GroupRepoSuite) TestGetAccountCount_Empty() { s.Require().Zero(count) } +// TestListWithFilters_ActiveAccountCount_LessThanTotal 验证 ActiveAccountCount 正确区分可用与不可用账号。 +// 当分组内存在 disabled 或 schedulable=false 的账号时,ActiveAccountCount 必须小于 AccountCount, +// 且与 GetAccountCount 返回的 active 值一致。 +func (s *GroupRepoSuite) TestListWithFilters_ActiveAccountCount_LessThanTotal() { + g := &service.Group{ + Name: "g-mixed-status", + Platform: service.PlatformAnthropic, + RateMultiplier: 1.0, + IsExclusive: false, + Status: service.StatusActive, + SubscriptionType: service.SubscriptionTypeStandard, + } + s.Require().NoError(s.repo.Create(s.ctx, g)) + + insertAccount := func(name, status string, schedulable bool) int64 { + var id int64 + s.Require().NoError(scanSingleRow( + s.ctx, s.tx, + "INSERT INTO accounts (name, platform, type, status, schedulable) VALUES ($1, $2, $3, $4, $5) RETURNING id", + []any{name, service.PlatformAnthropic, service.AccountTypeOAuth, status, schedulable}, + &id, + )) + return id + } + link := func(accountID int64, priority int) { + _, 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, priority) + s.Require().NoError(err) + } + + // account 1: active + schedulable → counts toward both total and active + link(insertAccount("acc-active-sched", service.StatusActive, true), 1) + // account 2: disabled → counts toward total only + link(insertAccount("acc-disabled", service.StatusDisabled, true), 2) + // account 3: active + not schedulable → counts toward total only + link(insertAccount("acc-unschedulable", service.StatusActive, false), 3) + + // --- ListWithFilters path --- + isExclusive := false + groups, _, err := s.repo.ListWithFilters(s.ctx, + pagination.PaginationParams{Page: 1, PageSize: 100}, + service.PlatformAnthropic, service.StatusActive, "", &isExclusive) + s.Require().NoError(err) + + var found *service.Group + for i := range groups { + if groups[i].ID == g.ID { + found = &groups[i] + break + } + } + s.Require().NotNil(found, "created group must appear in ListWithFilters result") + s.Assert().Equal(int64(3), found.AccountCount, "AccountCount must count all 3 accounts") + s.Assert().Equal(int64(1), found.ActiveAccountCount, "ActiveAccountCount must count only the active+schedulable account") + + // --- GetAccountCount must return identical values --- + total, active, err := s.repo.GetAccountCount(s.ctx, g.ID) + s.Require().NoError(err) + s.Assert().Equal(found.AccountCount, total, "GetAccountCount total must match ListWithFilters AccountCount") + s.Assert().Equal(found.ActiveAccountCount, active, "GetAccountCount active must match ListWithFilters ActiveAccountCount") +} + +// TestListWithFilters_RateLimitedAccountCount 验证 RateLimitedAccountCount 正确统计临时受限账号。 +// 受限账号(rate_limit_reset_at 尚未过期)仍然计入 ActiveAccountCount, +// 同时额外出现在 RateLimitedAccountCount 中。 +func (s *GroupRepoSuite) TestListWithFilters_RateLimitedAccountCount() { + g := &service.Group{ + Name: "g-rate-limited", + Platform: service.PlatformAnthropic, + RateMultiplier: 1.0, + IsExclusive: false, + Status: service.StatusActive, + SubscriptionType: service.SubscriptionTypeStandard, + } + s.Require().NoError(s.repo.Create(s.ctx, g)) + + var normalID int64 + s.Require().NoError(scanSingleRow(s.ctx, s.tx, + "INSERT INTO accounts (name, platform, type) VALUES ($1, $2, $3) RETURNING id", + []any{"acc-normal", service.PlatformAnthropic, service.AccountTypeOAuth}, + &normalID)) + + var rateLimitedID int64 + s.Require().NoError(scanSingleRow(s.ctx, s.tx, + "INSERT INTO accounts (name, platform, type, rate_limit_reset_at) VALUES ($1, $2, $3, NOW() + INTERVAL '1 hour') RETURNING id", + []any{"acc-rate-limited", service.PlatformAnthropic, service.AccountTypeOAuth}, + &rateLimitedID)) + + _, err := s.tx.ExecContext(s.ctx, + "INSERT INTO account_groups (account_id, group_id, priority, created_at) VALUES ($1, $2, $3, NOW())", + normalID, g.ID, 1) + s.Require().NoError(err) + _, err = s.tx.ExecContext(s.ctx, + "INSERT INTO account_groups (account_id, group_id, priority, created_at) VALUES ($1, $2, $3, NOW())", + rateLimitedID, g.ID, 2) + s.Require().NoError(err) + + isExclusive := false + groups, _, err := s.repo.ListWithFilters(s.ctx, + pagination.PaginationParams{Page: 1, PageSize: 100}, + service.PlatformAnthropic, service.StatusActive, "", &isExclusive) + s.Require().NoError(err) + + var found *service.Group + for i := range groups { + if groups[i].ID == g.ID { + found = &groups[i] + break + } + } + s.Require().NotNil(found, "created group must appear in ListWithFilters result") + s.Assert().Equal(int64(2), found.AccountCount, "AccountCount must be 2") + // rate-limited account is still active+schedulable, so it counts toward active + s.Assert().Equal(int64(2), found.ActiveAccountCount, "rate-limited account still counts as active") + s.Assert().Equal(int64(1), found.RateLimitedAccountCount, "RateLimitedAccountCount must be 1") +} + // --- DeleteAccountGroupsByGroupID --- func (s *GroupRepoSuite) TestDeleteAccountGroupsByGroupID() { diff --git a/backend/internal/repository/group_repo_sort_integration_test.go b/backend/internal/repository/group_repo_sort_integration_test.go index 85b2efcc..39e1ec78 100644 --- a/backend/internal/repository/group_repo_sort_integration_test.go +++ b/backend/internal/repository/group_repo_sort_integration_test.go @@ -7,6 +7,67 @@ import ( "github.com/Wei-Shaw/sub2api/internal/service" ) +// TestListWithAccountCountSort_AttachesActiveCount 验证通过 account_count 排序时, +// ActiveAccountCount 与 AccountCount 都被正确附加到返回结果中, +// 且排序基于 total 账号数而非 active 账号数。 +func (s *GroupRepoSuite) TestListWithAccountCountSort_AttachesActiveCount() { + // Group A: 2 total, 1 active (1 disabled account) + gA := &service.Group{Name: "sort-count-a", Platform: service.PlatformAnthropic, RateMultiplier: 1, Status: service.StatusActive, SubscriptionType: service.SubscriptionTypeStandard} + // Group B: 1 total, 1 active + gB := &service.Group{Name: "sort-count-b", Platform: service.PlatformAnthropic, RateMultiplier: 1, Status: service.StatusActive, SubscriptionType: service.SubscriptionTypeStandard} + s.Require().NoError(s.repo.Create(s.ctx, gA)) + s.Require().NoError(s.repo.Create(s.ctx, gB)) + + insertAccount := func(name, status string) int64 { + var id int64 + s.Require().NoError(scanSingleRow(s.ctx, s.tx, + "INSERT INTO accounts (name, platform, type, status) VALUES ($1, $2, $3, $4) RETURNING id", + []any{name, service.PlatformAnthropic, service.AccountTypeOAuth, status}, + &id)) + return id + } + link := func(accountID, groupID int64, priority int) { + _, err := s.tx.ExecContext(s.ctx, + "INSERT INTO account_groups (account_id, group_id, priority, created_at) VALUES ($1, $2, $3, NOW())", + accountID, groupID, priority) + s.Require().NoError(err) + } + + // gA: 1 active + 1 disabled → total=2, active=1 + link(insertAccount("sa-active", service.StatusActive), gA.ID, 1) + link(insertAccount("sa-disabled", service.StatusDisabled), gA.ID, 2) + // gB: 1 active → total=1, active=1 + link(insertAccount("sb-active", service.StatusActive), gB.ID, 1) + + groups, _, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{ + Page: 1, PageSize: 100, SortBy: "account_count", SortOrder: "desc", + }, service.PlatformAnthropic, service.StatusActive, "", nil) + s.Require().NoError(err) + + byID := make(map[int64]service.Group, len(groups)) + for _, g := range groups { + byID[g.ID] = g + } + + s.Require().Contains(byID, gA.ID, "gA must appear in results") + s.Require().Contains(byID, gB.ID, "gB must appear in results") + + cA := byID[gA.ID] + s.Assert().Equal(int64(2), cA.AccountCount, "gA AccountCount must be 2") + s.Assert().Equal(int64(1), cA.ActiveAccountCount, "gA ActiveAccountCount must be 1") + + cB := byID[gB.ID] + s.Assert().Equal(int64(1), cB.AccountCount, "gB AccountCount must be 1") + s.Assert().Equal(int64(1), cB.ActiveAccountCount, "gB ActiveAccountCount must be 1") + + // Sort is by total (not active): gA (total=2) must rank higher than gB (total=1) in desc order + indexByID := make(map[int64]int, len(groups)) + for i, g := range groups { + indexByID[g.ID] = i + } + s.Assert().Less(indexByID[gA.ID], indexByID[gB.ID], "gA (total=2) must rank above gB (total=1) with account_count desc") +} + func (s *GroupRepoSuite) TestList_DefaultSortBySortOrderAsc() { g1 := &service.Group{Name: "g1", Platform: service.PlatformAnthropic, RateMultiplier: 1, Status: service.StatusActive, SubscriptionType: service.SubscriptionTypeStandard, SortOrder: 20} g2 := &service.Group{Name: "g2", Platform: service.PlatformAnthropic, RateMultiplier: 1, Status: service.StatusActive, SubscriptionType: service.SubscriptionTypeStandard, SortOrder: 10}