//go:build unit package service import ( "context" "errors" "net/http" "strings" "testing" "time" "unicode/utf8" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/stretchr/testify/require" ) type duplicateAccountRepoStub struct { *sparkShadowRepoStub atomicCreateErr error accountGroupsOf map[int64][]AccountGroup } func newDuplicateAccountRepoStub() *duplicateAccountRepoStub { return &duplicateAccountRepoStub{ sparkShadowRepoStub: newSparkShadowRepoStub(), accountGroupsOf: make(map[int64][]AccountGroup), } } func (s *duplicateAccountRepoStub) CreateWithAccountGroups(ctx context.Context, account *Account, groups []AccountGroup) error { if s.atomicCreateErr != nil { return s.atomicCreateErr } groupIDs := make([]int64, 0, len(groups)) for _, group := range groups { groupIDs = append(groupIDs, group.GroupID) } account.GroupIDs = groupIDs if err := s.Create(ctx, account); err != nil { return err } clonedGroups := make([]AccountGroup, len(groups)) copy(clonedGroups, groups) for i := range clonedGroups { clonedGroups[i].AccountID = account.ID } account.AccountGroups = clonedGroups s.accountGroupsOf[account.ID] = clonedGroups if len(groupIDs) > 0 { s.groupsOf[account.ID] = append([]int64(nil), groupIDs...) } stored := *account s.accounts[account.ID] = &stored s.mockAccountRepoForGemini.accountsByID[account.ID] = &stored return nil } func (s *duplicateAccountRepoStub) FindByExtraField(_ context.Context, key string, value any) ([]Account, error) { wanted, ok := value.(string) if !ok { return nil, nil } var matches []Account for _, account := range s.accounts { if actual, ok := account.Extra[key].(string); ok && actual == wanted { matches = append(matches, *account) } } return matches, nil } func TestDuplicateAccountCopiesConfigurationAndResetsRuntimeState(t *testing.T) { ctx := context.Background() repo := newDuplicateAccountRepoStub() svc := &adminServiceImpl{accountRepo: repo, accountDuplicateRepo: repo} notes := "keep this note" proxyID := int64(17) originalProxyID := int64(11) rateMultiplier := 1.25 loadFactor := 9 expiresAt := time.Date(2027, time.March, 4, 5, 6, 7, 0, time.UTC) rateLimitedAt := time.Now().Add(-time.Minute) rateLimitResetAt := time.Now().Add(time.Hour) overloadUntil := time.Now().Add(2 * time.Hour) tempUnschedulableUntil := time.Now().Add(3 * time.Hour) sessionWindowStart := time.Now().Add(-2 * time.Hour) sessionWindowEnd := time.Now().Add(2 * time.Hour) source := &Account{ Name: "primary", Notes: ¬es, Platform: PlatformAnthropic, Type: AccountTypeAPIKey, ProxyID: &proxyID, ProxyFallbackOriginID: &originalProxyID, Concurrency: 6, Priority: 40, RateMultiplier: &rateMultiplier, LoadFactor: &loadFactor, Status: StatusError, Schedulable: true, ErrorMessage: "upstream unavailable", ExpiresAt: &expiresAt, AutoPauseOnExpired: false, Credentials: map[string]any{ "api_key": "secret", "nested": map[string]any{"token": "source-token"}, }, Extra: map[string]any{ "config": map[string]any{"region": "us-east-1"}, "items": []any{map[string]any{"enabled": true}}, "quota_limit": 1000, "quota_used": 450, "quota_daily_used": 25, "quota_daily_start": "2026-07-15T00:00:00Z", "model_rate_limits": map[string]any{"gpt-5": "2099-01-01T00:00:00Z"}, "codex_5h_used_percent": 80, "codex_cli_only": true, "grok_usage_snapshot": map[string]any{"status_code": 429}, "openai_responses_supported": false, "openai_compact_checked_at": "2026-07-15T00:00:00Z", "session_window_utilization": 0.8, "passive_usage_sampled_at": "2026-07-15T00:00:00Z", "antigravity_force_token_refresh": true, "antigravity_credits_overages": map[string]any{"enabled": true}, "crs_account_id": "remote-42", "crs_kind": "openai-api-key", "crs_synced_at": "2026-07-15T00:00:00Z", }, GroupIDs: []int64{7, 3}, AccountGroups: []AccountGroup{{GroupID: 7, Priority: 50}, {GroupID: 3, Priority: 7}}, RateLimitedAt: &rateLimitedAt, RateLimitResetAt: &rateLimitResetAt, OverloadUntil: &overloadUntil, TempUnschedulableUntil: &tempUnschedulableUntil, TempUnschedulableReason: "maintenance", SessionWindowStart: &sessionWindowStart, SessionWindowEnd: &sessionWindowEnd, SessionWindowStatus: "active", } source.Extra[UpstreamBillingProbeEnabledExtraKey] = true source.Extra[UpstreamBillingRateSyncEnabledExtraKey] = true source.Extra[UpstreamBillingProbeExtraKey] = map[string]any{"status": "ok"} require.NoError(t, repo.Create(ctx, source)) duplicate, err := svc.DuplicateAccount(ctx, source.ID, "admin:1", "") require.NoError(t, err) require.NotEqual(t, source.ID, duplicate.ID) require.Equal(t, "primary (Copy)", duplicate.Name) require.Equal(t, source.Platform, duplicate.Platform) require.Equal(t, source.Type, duplicate.Type) require.Equal(t, source.Concurrency, duplicate.Concurrency) require.Equal(t, source.Priority, duplicate.Priority) require.Equal(t, source.AutoPauseOnExpired, duplicate.AutoPauseOnExpired) require.Equal(t, source.GroupIDs, duplicate.GroupIDs) require.Equal(t, source.Credentials, duplicate.Credentials) require.Equal(t, map[string]any{ "config": map[string]any{"region": "us-east-1"}, "items": []any{map[string]any{"enabled": true}}, "quota_limit": float64(1000), "codex_cli_only": true, }, duplicate.Extra) require.NotContains(t, duplicate.Extra, UpstreamBillingRateSyncEnabledExtraKey) require.NotNil(t, duplicate.ExpiresAt) require.True(t, source.ExpiresAt.Equal(*duplicate.ExpiresAt)) require.Equal(t, source.Notes, duplicate.Notes) require.Equal(t, source.ProxyFallbackOriginID, duplicate.ProxyID) require.Equal(t, source.RateMultiplier, duplicate.RateMultiplier) require.Equal(t, source.LoadFactor, duplicate.LoadFactor) require.Equal(t, source.GroupIDs, repo.groupsOf[duplicate.ID]) require.Equal(t, []AccountGroup{ {AccountID: duplicate.ID, GroupID: 7, Priority: 50}, {AccountID: duplicate.ID, GroupID: 3, Priority: 7}, }, repo.accountGroupsOf[duplicate.ID]) require.Equal(t, StatusActive, duplicate.Status) require.False(t, duplicate.Schedulable) require.Empty(t, duplicate.ErrorMessage) require.Nil(t, duplicate.LastUsedAt) require.Nil(t, duplicate.RateLimitedAt) require.Nil(t, duplicate.RateLimitResetAt) require.Nil(t, duplicate.OverloadUntil) require.Nil(t, duplicate.TempUnschedulableUntil) require.Empty(t, duplicate.TempUnschedulableReason) require.Nil(t, duplicate.SessionWindowStart) require.Nil(t, duplicate.SessionWindowEnd) require.Empty(t, duplicate.SessionWindowStatus) duplicate.Credentials["nested"].(map[string]any)["token"] = "changed" duplicate.Extra["config"].(map[string]any)["region"] = "changed" duplicate.Extra["items"].([]any)[0].(map[string]any)["enabled"] = false storedSource, getErr := repo.GetByID(ctx, source.ID) require.NoError(t, getErr) require.Equal(t, "source-token", storedSource.Credentials["nested"].(map[string]any)["token"]) require.Equal(t, "us-east-1", storedSource.Extra["config"].(map[string]any)["region"]) require.Equal(t, true, storedSource.Extra["items"].([]any)[0].(map[string]any)["enabled"]) require.Equal(t, "remote-42", storedSource.Extra["crs_account_id"]) } func TestDuplicateAccountRejectsCredentialShadow(t *testing.T) { ctx := context.Background() repo := newDuplicateAccountRepoStub() svc := &adminServiceImpl{accountRepo: repo, accountDuplicateRepo: repo} parentID := int64(99) shadow := &Account{ Name: "shadow", Platform: PlatformOpenAI, Type: AccountTypeOAuth, ParentAccountID: &parentID, QuotaDimension: QuotaDimensionSpark, } require.NoError(t, repo.Create(ctx, shadow)) _, err := svc.DuplicateAccount(ctx, shadow.ID, "admin:1", "") require.Error(t, err) require.Equal(t, http.StatusBadRequest, infraerrors.Code(err)) require.Equal(t, "ACCOUNT_DUPLICATE_SHADOW_UNSUPPORTED", infraerrors.Reason(err)) require.Len(t, repo.accounts, 1) } func TestDuplicateAccountRejectsRotatingOrUnknownCredentialTypes(t *testing.T) { for _, accountType := range []string{AccountTypeOAuth, AccountTypeSetupToken, "legacy-cookie"} { t.Run(accountType, func(t *testing.T) { ctx := context.Background() repo := newDuplicateAccountRepoStub() svc := &adminServiceImpl{accountRepo: repo, accountDuplicateRepo: repo} source := &Account{ Name: "rotating-credential-account", Platform: PlatformOpenAI, Type: accountType, Credentials: map[string]any{"refresh_token": "shared-token"}, } require.NoError(t, repo.Create(ctx, source)) _, err := svc.DuplicateAccount(ctx, source.ID, "admin:1", "") require.Error(t, err) require.Equal(t, http.StatusBadRequest, infraerrors.Code(err)) require.Equal(t, "ACCOUNT_DUPLICATE_CREDENTIAL_TYPE_UNSUPPORTED", infraerrors.Reason(err)) require.Len(t, repo.accounts, 1) }) } } func TestDuplicateAccountPreservesUngroupedState(t *testing.T) { ctx := context.Background() repo := newDuplicateAccountRepoStub() svc := &adminServiceImpl{accountRepo: repo, accountDuplicateRepo: repo} source := &Account{ Name: "ungrouped", Platform: PlatformAnthropic, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "secret"}, GroupIDs: nil, } require.NoError(t, repo.Create(ctx, source)) duplicate, err := svc.DuplicateAccount(ctx, source.ID, "admin:1", "") require.NoError(t, err) require.Empty(t, duplicate.GroupIDs) require.NotContains(t, repo.groupsOf, duplicate.ID) } func TestDuplicateAccountAtomicCreateFailureLeavesNoOrphan(t *testing.T) { ctx := context.Background() repo := newDuplicateAccountRepoStub() svc := &adminServiceImpl{accountRepo: repo, accountDuplicateRepo: repo} source := &Account{ Name: "source", Platform: PlatformAnthropic, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "secret"}, GroupIDs: []int64{7}, AccountGroups: []AccountGroup{{GroupID: 7, Priority: 25}}, } require.NoError(t, repo.Create(ctx, source)) repo.atomicCreateErr = errors.New("group binding failed") _, err := svc.DuplicateAccount(ctx, source.ID, "admin:1", "") require.ErrorContains(t, err, "group binding failed") require.Len(t, repo.accounts, 1) } func TestDuplicateAccountNamePreservesSuffixWithinSchemaLimit(t *testing.T) { name := duplicateAccountName(strings.Repeat("界", 100)) require.Equal(t, 100, utf8.RuneCountInString(name)) require.True(t, strings.HasSuffix(name, " (Copy)")) } func TestDuplicateAccountReturnsExistingCopyForSameOperationKey(t *testing.T) { ctx := context.Background() repo := newDuplicateAccountRepoStub() svc := &adminServiceImpl{accountRepo: repo, accountDuplicateRepo: repo} source := &Account{ Name: "source", Platform: PlatformAnthropic, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "secret"}, } require.NoError(t, repo.Create(ctx, source)) first, err := svc.DuplicateAccount(ctx, source.ID, "admin:7", "stable-operation-key") require.NoError(t, err) second, err := svc.DuplicateAccount(ctx, source.ID, "admin:7", "stable-operation-key") require.NoError(t, err) recovered, err := svc.RecoverDuplicateAccount(ctx, source.ID, "admin:7", "stable-operation-key") require.NoError(t, err) otherAdminRecovery, err := svc.RecoverDuplicateAccount(ctx, source.ID, "admin:8", "stable-operation-key") require.NoError(t, err) otherAdminCopy, err := svc.DuplicateAccount(ctx, source.ID, "admin:8", "stable-operation-key") require.NoError(t, err) require.Equal(t, first.ID, second.ID) require.Equal(t, first.ID, recovered.ID) require.Nil(t, otherAdminRecovery, "durable recovery identity must remain scoped to the initiating admin") require.NotEqual(t, first.ID, otherAdminCopy.ID) require.Len(t, repo.accounts, 3) require.NotEmpty(t, first.Extra[duplicateAccountOperationIDExtraKey]) }