| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400 |
- package repository
- import (
- "context"
- "database/sql"
- "database/sql/driver"
- "regexp"
- "strings"
- "testing"
- "time"
- sqlmock "github.com/DATA-DOG/go-sqlmock"
- "github.com/Wei-Shaw/sub2api/internal/service"
- "github.com/stretchr/testify/require"
- )
- func TestAccountRepository_SetTempUnschedulable_NoRowsAffectedDoesNotWriteOutbox(t *testing.T) {
- exec := &recordingSQLExecutor{result: rowsAffectedResult(0)}
- repo := newAccountRepositoryWithSQL(nil, exec, nil)
- until := time.Now().Add(10 * time.Minute)
- err := repo.SetTempUnschedulable(context.Background(), 42, until, "retry")
- require.NoError(t, err)
- require.Len(t, exec.execQueries, 1)
- require.Contains(t, exec.execQueries[0], "UPDATE accounts")
- require.NotContains(t, strings.Join(exec.execQueries, "\n"), "scheduler_outbox")
- }
- func TestAccountRepository_ResetQuotaUsedAndClearRateLimitCooldown_NoRowsAffectedReturnsNotFoundWithoutOutbox(t *testing.T) {
- exec := &recordingSQLExecutor{result: rowsAffectedResult(0)}
- repo := newAccountRepositoryWithSQL(nil, exec, nil)
- err := repo.ResetQuotaUsedAndClearRateLimitCooldown(context.Background(), 42)
- require.ErrorIs(t, err, service.ErrAccountNotFound)
- require.Len(t, exec.execQueries, 1)
- require.Contains(t, exec.execQueries[0], "UPDATE accounts")
- require.NotContains(t, strings.Join(exec.execQueries, "\n"), "scheduler_outbox")
- }
- func TestAccountRepository_GrokCredentialConditionalMutationsAreEligibleAndAtomicallyPropagated(t *testing.T) {
- proxyID := int64(77)
- snapshot := service.GrokCredentialMutationSnapshot{
- CredentialsJSON: `{"access_token":"access","refresh_token":"refresh","_token_version":123}`,
- ProxyID: &proxyID,
- }
- t.Run("permanent", func(t *testing.T) {
- exec := &recordingSQLExecutor{result: rowsAffectedResult(0)}
- repo := newAccountRepositoryWithSQL(nil, exec, nil)
- updated, err := repo.SetGrokCredentialErrorIfMatch(context.Background(), 42, snapshot, "revoked")
- require.NoError(t, err)
- require.False(t, updated)
- require.Len(t, exec.execQueries, 1)
- normalized := normalizeSQLWhitespace(exec.execQueries[0])
- require.Contains(t, normalized, "WITH updated AS ( UPDATE accounts AS a")
- require.Contains(t, normalized, "a.schedulable IS TRUE")
- require.Contains(t, normalized, "a.temp_unschedulable_until IS NULL OR a.temp_unschedulable_until <= NOW()")
- require.Contains(t, normalized, "a.rate_limit_reset_at IS NULL OR a.rate_limit_reset_at <= NOW()")
- require.Contains(t, normalized, "a.overload_until IS NULL OR a.overload_until <= NOW()")
- require.Contains(t, normalized, "a.credentials = $7::jsonb")
- require.Contains(t, normalized, "a.proxy_id IS NOT DISTINCT FROM $8")
- require.Contains(t, normalized, "NOT EXISTS ( SELECT 1 FROM proxies p")
- require.Contains(t, normalized, "INSERT INTO scheduler_outbox")
- require.Len(t, exec.execArgs[0], 10)
- require.Equal(t, snapshot.CredentialsJSON, exec.execArgs[0][6])
- require.Equal(t, &proxyID, exec.execArgs[0][7])
- require.Equal(t, string(service.GrokCredentialReasonProxyInvalid), exec.execArgs[0][8])
- require.Equal(t, service.SchedulerOutboxEventAccountChanged, exec.execArgs[0][9])
- })
- t.Run("transient", func(t *testing.T) {
- exec := &recordingSQLExecutor{result: rowsAffectedResult(0)}
- repo := newAccountRepositoryWithSQL(nil, exec, nil)
- updated, err := repo.SetGrokCredentialTempUnschedulableIfMatch(
- context.Background(), 42, snapshot, time.Now().Add(time.Minute), "temporary",
- )
- require.NoError(t, err)
- require.False(t, updated)
- require.Len(t, exec.execQueries, 1)
- normalized := normalizeSQLWhitespace(exec.execQueries[0])
- require.Contains(t, normalized, "WITH updated AS ( UPDATE accounts AS a")
- require.Contains(t, normalized, "a.schedulable IS TRUE")
- require.Contains(t, normalized, "a.temp_unschedulable_until IS NULL OR a.temp_unschedulable_until <= NOW()")
- require.Contains(t, normalized, "a.rate_limit_reset_at IS NULL OR a.rate_limit_reset_at <= NOW()")
- require.Contains(t, normalized, "a.overload_until IS NULL OR a.overload_until <= NOW()")
- require.Contains(t, normalized, "a.credentials = $7::jsonb")
- require.Contains(t, normalized, "a.proxy_id IS NOT DISTINCT FROM $8")
- require.Contains(t, normalized, "INSERT INTO scheduler_outbox")
- require.Len(t, exec.execArgs[0], 9)
- require.Equal(t, snapshot.CredentialsJSON, exec.execArgs[0][6])
- require.Equal(t, &proxyID, exec.execArgs[0][7])
- require.Equal(t, service.SchedulerOutboxEventAccountChanged, exec.execArgs[0][8])
- })
- }
- func TestAccountRepository_GrokCredentialCommitCarriesOutboxAcrossCallerCancellation(t *testing.T) {
- snapshot := service.GrokCredentialMutationSnapshot{CredentialsJSON: `{"access_token":"access","refresh_token":"refresh"}`}
- tests := []struct {
- name string
- mutate func(context.Context, *accountRepository) (bool, error)
- }{
- {
- name: "permanent",
- mutate: func(ctx context.Context, repo *accountRepository) (bool, error) {
- return repo.SetGrokCredentialErrorIfMatch(ctx, 42, snapshot, string(service.GrokCredentialReasonRevoked))
- },
- },
- {
- name: "transient",
- mutate: func(ctx context.Context, repo *accountRepository) (bool, error) {
- return repo.SetGrokCredentialTempUnschedulableIfMatch(ctx, 42, snapshot, time.Now().Add(time.Minute), string(service.GrokCredentialReasonRefreshTransient))
- },
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- ctx, cancel := context.WithCancel(context.Background())
- exec := &recordingSQLExecutor{result: rowsAffectedResult(1), afterExec: cancel}
- repo := newAccountRepositoryWithSQL(nil, exec, nil)
- updated, err := tt.mutate(ctx, repo)
- require.NoError(t, err)
- require.True(t, updated)
- require.ErrorIs(t, ctx.Err(), context.Canceled)
- require.Len(t, exec.execQueries, 1, "state update and scheduler outbox must share one atomic SQL statement")
- require.Contains(t, normalizeSQLWhitespace(exec.execQueries[0]), "INSERT INTO scheduler_outbox")
- })
- }
- }
- func TestAccountRepository_SetGrokOAuthErrorIfCredentialsUnchanged_RequiresActiveExactCredentialMatch(t *testing.T) {
- exec := &recordingSQLExecutor{result: rowsAffectedResult(0)}
- repo := newAccountRepositoryWithSQL(nil, exec, nil)
- applied, err := repo.SetGrokOAuthErrorIfCredentialsUnchanged(
- context.Background(),
- 42,
- map[string]any{"access_token": "observed", "_token_version": int64(7)},
- "missing refresh token",
- )
- require.NoError(t, err)
- require.False(t, applied)
- require.Len(t, exec.execQueries, 1, "the account mutation and conditional outbox insert must be one statement")
- normalized := normalizeSQLWhitespace(exec.execQueries[0])
- require.Contains(t, normalized, "WITH updated AS")
- require.Contains(t, normalized, "INSERT INTO scheduler_outbox")
- require.Contains(t, normalized, "FROM updated")
- require.Contains(t, normalized, "platform = $4")
- require.Contains(t, normalized, "type = $5")
- require.Contains(t, normalized, "status = $6")
- require.Contains(t, normalized, "credentials = $7::jsonb")
- require.Contains(t, normalized, "NULLIF(BTRIM(a.credentials->>'refresh_token'), '') IS NULL")
- require.Len(t, exec.execArgs, 1)
- require.Equal(t, service.StatusActive, exec.execArgs[0][5])
- require.Contains(t, exec.execArgs[0][6], `"_token_version":7`)
- }
- func TestAccountRepository_SetGrokOAuthErrorIfCredentialsUnchanged_AppliedWritesOutbox(t *testing.T) {
- exec := &recordingSQLExecutor{result: rowsAffectedResult(1)}
- repo := newAccountRepositoryWithSQL(nil, exec, nil)
- applied, err := repo.SetGrokOAuthErrorIfCredentialsUnchanged(
- context.Background(),
- 42,
- map[string]any{"access_token": "observed"},
- "missing refresh token",
- )
- require.NoError(t, err)
- require.True(t, applied)
- require.Len(t, exec.execQueries, 1)
- normalized := normalizeSQLWhitespace(exec.execQueries[0])
- require.Contains(t, normalized, "WITH updated AS")
- require.Contains(t, normalized, "INSERT INTO scheduler_outbox")
- require.Contains(t, normalized, "SELECT $8, updated.id, NULL, NULL FROM updated")
- }
- func TestAccountRepository_SetGrokOAuthRefreshErrorIfCredentialsUnchanged_UsesAttemptCredentialsAndProxy(t *testing.T) {
- exec := &recordingSQLExecutor{result: rowsAffectedResult(0)}
- repo := newAccountRepositoryWithSQL(nil, exec, nil)
- proxyID := int64(17)
- applied, err := repo.SetGrokOAuthRefreshErrorIfCredentialsUnchanged(
- context.Background(),
- 42,
- map[string]any{"refresh_token": "attempted", "_token_version": int64(7)},
- &proxyID,
- "revoked",
- )
- require.NoError(t, err)
- require.False(t, applied)
- require.Len(t, exec.execQueries, 1)
- normalized := normalizeSQLWhitespace(exec.execQueries[0])
- require.Contains(t, normalized, "credentials = $7::jsonb")
- require.Contains(t, normalized, "proxy_id IS NOT DISTINCT FROM $8")
- require.NotContains(t, normalized, "credentials->>'refresh_token'",
- "background invalid_grant CAS must accept the attempted refresh token; only reconciliation requires it missing")
- require.Equal(t, &proxyID, exec.execArgs[0][7])
- require.Contains(t, normalized, "INSERT INTO scheduler_outbox")
- require.Len(t, exec.execArgs[0], 9)
- }
- func TestAccountRepository_SetGrokOAuthRefreshTempUnschedulableIfCredentialsUnchanged_UsesAttemptCredentialsAndProxy(t *testing.T) {
- exec := &recordingSQLExecutor{result: rowsAffectedResult(0)}
- repo := newAccountRepositoryWithSQL(nil, exec, nil)
- proxyID := int64(19)
- applied, err := repo.SetGrokOAuthRefreshTempUnschedulableIfCredentialsUnchanged(
- context.Background(),
- 42,
- map[string]any{"refresh_token": "attempted", "_token_version": int64(8)},
- &proxyID,
- time.Now().Add(10*time.Minute),
- "retry exhausted",
- )
- require.NoError(t, err)
- require.False(t, applied)
- require.Len(t, exec.execQueries, 1)
- normalized := normalizeSQLWhitespace(exec.execQueries[0])
- require.Contains(t, normalized, "credentials = $7::jsonb")
- require.Contains(t, normalized, "proxy_id IS NOT DISTINCT FROM $8")
- require.Contains(t, normalized, "a.temp_unschedulable_until IS NULL OR a.temp_unschedulable_until < $1")
- require.Len(t, exec.execArgs[0], 9)
- require.Equal(t, &proxyID, exec.execArgs[0][7])
- require.Contains(t, normalized, "INSERT INTO scheduler_outbox")
- }
- func TestAccountRepository_UpdateGrokOAuthCredentialsIfUnchanged_UsesExactAttemptStateAndAtomicOutbox(t *testing.T) {
- exec := &recordingSQLExecutor{result: rowsAffectedResult(1)}
- repo := newAccountRepositoryWithSQL(nil, exec, nil)
- proxyID := int64(29)
- applied, err := repo.UpdateGrokOAuthCredentialsIfUnchanged(
- context.Background(),
- 42,
- map[string]any{"refresh_token": "attempted", "_token_version": int64(9)},
- &proxyID,
- map[string]any{"refresh_token": "rotated", "_token_version": int64(10)},
- )
- require.NoError(t, err)
- require.True(t, applied)
- require.Len(t, exec.execQueries, 1)
- normalized := normalizeSQLWhitespace(exec.execQueries[0])
- require.Contains(t, normalized, "WITH updated AS")
- require.Contains(t, normalized, "credentials = $1::jsonb")
- require.Contains(t, normalized, "credentials = $5::jsonb")
- require.Contains(t, normalized, "proxy_id IS NOT DISTINCT FROM $6")
- require.Contains(t, normalized, "INSERT INTO scheduler_outbox")
- require.Len(t, exec.execArgs[0], 7)
- require.Equal(t, &proxyID, exec.execArgs[0][5])
- }
- func TestAccountRepository_ListOAuthRefreshCandidatePage_SQLFilter(t *testing.T) {
- db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
- require.NoError(t, err)
- defer func() { _ = db.Close() }()
- var capturedSQL string
- var capturedArgs []any
- mock.ExpectQuery("SELECT id").
- WillReturnRows(sqlmock.NewRows([]string{"id"})).
- WillDelayFor(0)
- repo := newAccountRepositoryWithSQL(nil, captureQuerySQL{db: db, captured: &capturedSQL, args: &capturedArgs}, nil)
- page, err := repo.ListOAuthRefreshCandidatePage(context.Background(), service.OAuthRefreshPageOptions{
- Platforms: []string{service.PlatformAnthropic, service.PlatformOpenAI, service.PlatformGemini, service.PlatformAntigravity, service.PlatformGrok},
- AfterID: 100,
- Limit: 200,
- ActiveOnly: true,
- IncludeSetupToken: true,
- RequireRefreshToken: true,
- ExcludeRetryCooldown: true,
- })
- require.NoError(t, err)
- require.Empty(t, page.Accounts)
- normalized := normalizeSQLWhitespace(capturedSQL)
- require.Contains(t, normalized, "deleted_at IS NULL")
- require.Contains(t, normalized, "schedulable = TRUE",
- "permanently unschedulable accounts must not remain OAuth refresh candidates")
- require.Contains(t, normalized, "status = 'active'")
- // setup-token 的 access_token 同为 8h 短期令牌,必须与 oauth 一起纳入后台刷新候选
- require.Contains(t, normalized, "type IN ('oauth', 'setup-token')")
- require.Contains(t, normalized, "platform = ANY($1)")
- require.NotContains(t, normalized, "platform IN ('anthropic'",
- "candidate platforms must come from the refresher registry instead of a second hard-coded list")
- require.Contains(t, normalized, "credentials ? 'refresh_token'")
- require.Contains(t, normalized, "btrim(credentials->>'refresh_token') <> ''")
- require.Contains(t, normalized, "temp_unschedulable_until > NOW()")
- require.Contains(t, normalized, "temp_unschedulable_reason LIKE 'token refresh retry exhausted:%'")
- require.Contains(t, normalized, "IS NOT TRUE",
- "must use IS NOT TRUE so accounts with NULL temp_unschedulable_until are not silently excluded by PG 3-valued logic")
- require.NotContains(t, normalized, "AND NOT (",
- "plain NOT (...) excludes NULL temp_unschedulable_until rows (the common healthy case)")
- require.Contains(t, normalized, "id > $2")
- require.Contains(t, normalized, "ORDER BY id ASC")
- require.Contains(t, normalized, "LIMIT $3")
- require.NotContains(t, normalized, "credentials->>'expires_at'")
- require.Len(t, capturedArgs, 3)
- require.Equal(t, int64(100), capturedArgs[1])
- require.Equal(t, 200, capturedArgs[2])
- valuer, ok := capturedArgs[0].(interface{ Value() (driver.Value, error) })
- require.True(t, ok)
- platforms, err := valuer.Value()
- require.NoError(t, err)
- require.Contains(t, platforms, service.PlatformGrok)
- require.NoError(t, mock.ExpectationsWereMet())
- }
- func TestAccountRepository_ListOAuthRefreshCandidatePage_ReconciliationExcludesAPIKeys(t *testing.T) {
- db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
- require.NoError(t, err)
- defer func() { _ = db.Close() }()
- var capturedSQL string
- mock.ExpectQuery("SELECT id").WillReturnRows(sqlmock.NewRows([]string{"id"}))
- repo := newAccountRepositoryWithSQL(nil, captureQuerySQL{db: db, captured: &capturedSQL}, nil)
- page, err := repo.ListOAuthRefreshCandidatePage(context.Background(), service.OAuthRefreshPageOptions{
- Platforms: []string{service.PlatformGrok},
- AfterID: 0,
- Limit: 50,
- })
- require.NoError(t, err)
- require.Empty(t, page.Accounts)
- normalized := normalizeSQLWhitespace(capturedSQL)
- require.Contains(t, normalized, "type = 'oauth'")
- require.NotContains(t, normalized, "type IN ('oauth', 'setup-token')")
- require.NotContains(t, normalized, "type = 'api-key'")
- require.NotContains(t, normalized, "credentials ? 'refresh_token'",
- "reconciliation must be able to find structurally invalid OAuth rows")
- require.Contains(t, normalized, "ORDER BY id ASC")
- require.NoError(t, mock.ExpectationsWereMet())
- }
- type captureQuerySQL struct {
- db *sql.DB
- captured *string
- args *[]any
- }
- func (c captureQuerySQL) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) {
- return c.db.ExecContext(ctx, query, args...)
- }
- func (c captureQuerySQL) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
- if c.captured != nil {
- *c.captured = query
- }
- if c.args != nil {
- *c.args = append([]any(nil), args...)
- }
- return c.db.QueryContext(ctx, query, args...)
- }
- func normalizeSQLWhitespace(sql string) string {
- return strings.Join(regexp.MustCompile(`\s+`).Split(strings.TrimSpace(sql), -1), " ")
- }
- type rowsAffectedResult int64
- func (r rowsAffectedResult) LastInsertId() (int64, error) { return 0, nil }
- func (r rowsAffectedResult) RowsAffected() (int64, error) { return int64(r), nil }
- type recordingSQLExecutor struct {
- result sql.Result
- err error
- afterExec func()
- execQueries []string
- execArgs [][]any
- }
- func (e *recordingSQLExecutor) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) {
- e.execQueries = append(e.execQueries, query)
- e.execArgs = append(e.execArgs, append([]any(nil), args...))
- if e.err != nil {
- return nil, e.err
- }
- if e.afterExec != nil {
- e.afterExec()
- }
- return e.result, nil
- }
- func (e *recordingSQLExecutor) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
- return nil, sql.ErrNoRows
- }
|