account_repo_temp_unsched_test.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400
  1. package repository
  2. import (
  3. "context"
  4. "database/sql"
  5. "database/sql/driver"
  6. "regexp"
  7. "strings"
  8. "testing"
  9. "time"
  10. sqlmock "github.com/DATA-DOG/go-sqlmock"
  11. "github.com/Wei-Shaw/sub2api/internal/service"
  12. "github.com/stretchr/testify/require"
  13. )
  14. func TestAccountRepository_SetTempUnschedulable_NoRowsAffectedDoesNotWriteOutbox(t *testing.T) {
  15. exec := &recordingSQLExecutor{result: rowsAffectedResult(0)}
  16. repo := newAccountRepositoryWithSQL(nil, exec, nil)
  17. until := time.Now().Add(10 * time.Minute)
  18. err := repo.SetTempUnschedulable(context.Background(), 42, until, "retry")
  19. require.NoError(t, err)
  20. require.Len(t, exec.execQueries, 1)
  21. require.Contains(t, exec.execQueries[0], "UPDATE accounts")
  22. require.NotContains(t, strings.Join(exec.execQueries, "\n"), "scheduler_outbox")
  23. }
  24. func TestAccountRepository_ResetQuotaUsedAndClearRateLimitCooldown_NoRowsAffectedReturnsNotFoundWithoutOutbox(t *testing.T) {
  25. exec := &recordingSQLExecutor{result: rowsAffectedResult(0)}
  26. repo := newAccountRepositoryWithSQL(nil, exec, nil)
  27. err := repo.ResetQuotaUsedAndClearRateLimitCooldown(context.Background(), 42)
  28. require.ErrorIs(t, err, service.ErrAccountNotFound)
  29. require.Len(t, exec.execQueries, 1)
  30. require.Contains(t, exec.execQueries[0], "UPDATE accounts")
  31. require.NotContains(t, strings.Join(exec.execQueries, "\n"), "scheduler_outbox")
  32. }
  33. func TestAccountRepository_GrokCredentialConditionalMutationsAreEligibleAndAtomicallyPropagated(t *testing.T) {
  34. proxyID := int64(77)
  35. snapshot := service.GrokCredentialMutationSnapshot{
  36. CredentialsJSON: `{"access_token":"access","refresh_token":"refresh","_token_version":123}`,
  37. ProxyID: &proxyID,
  38. }
  39. t.Run("permanent", func(t *testing.T) {
  40. exec := &recordingSQLExecutor{result: rowsAffectedResult(0)}
  41. repo := newAccountRepositoryWithSQL(nil, exec, nil)
  42. updated, err := repo.SetGrokCredentialErrorIfMatch(context.Background(), 42, snapshot, "revoked")
  43. require.NoError(t, err)
  44. require.False(t, updated)
  45. require.Len(t, exec.execQueries, 1)
  46. normalized := normalizeSQLWhitespace(exec.execQueries[0])
  47. require.Contains(t, normalized, "WITH updated AS ( UPDATE accounts AS a")
  48. require.Contains(t, normalized, "a.schedulable IS TRUE")
  49. require.Contains(t, normalized, "a.temp_unschedulable_until IS NULL OR a.temp_unschedulable_until <= NOW()")
  50. require.Contains(t, normalized, "a.rate_limit_reset_at IS NULL OR a.rate_limit_reset_at <= NOW()")
  51. require.Contains(t, normalized, "a.overload_until IS NULL OR a.overload_until <= NOW()")
  52. require.Contains(t, normalized, "a.credentials = $7::jsonb")
  53. require.Contains(t, normalized, "a.proxy_id IS NOT DISTINCT FROM $8")
  54. require.Contains(t, normalized, "NOT EXISTS ( SELECT 1 FROM proxies p")
  55. require.Contains(t, normalized, "INSERT INTO scheduler_outbox")
  56. require.Len(t, exec.execArgs[0], 10)
  57. require.Equal(t, snapshot.CredentialsJSON, exec.execArgs[0][6])
  58. require.Equal(t, &proxyID, exec.execArgs[0][7])
  59. require.Equal(t, string(service.GrokCredentialReasonProxyInvalid), exec.execArgs[0][8])
  60. require.Equal(t, service.SchedulerOutboxEventAccountChanged, exec.execArgs[0][9])
  61. })
  62. t.Run("transient", func(t *testing.T) {
  63. exec := &recordingSQLExecutor{result: rowsAffectedResult(0)}
  64. repo := newAccountRepositoryWithSQL(nil, exec, nil)
  65. updated, err := repo.SetGrokCredentialTempUnschedulableIfMatch(
  66. context.Background(), 42, snapshot, time.Now().Add(time.Minute), "temporary",
  67. )
  68. require.NoError(t, err)
  69. require.False(t, updated)
  70. require.Len(t, exec.execQueries, 1)
  71. normalized := normalizeSQLWhitespace(exec.execQueries[0])
  72. require.Contains(t, normalized, "WITH updated AS ( UPDATE accounts AS a")
  73. require.Contains(t, normalized, "a.schedulable IS TRUE")
  74. require.Contains(t, normalized, "a.temp_unschedulable_until IS NULL OR a.temp_unschedulable_until <= NOW()")
  75. require.Contains(t, normalized, "a.rate_limit_reset_at IS NULL OR a.rate_limit_reset_at <= NOW()")
  76. require.Contains(t, normalized, "a.overload_until IS NULL OR a.overload_until <= NOW()")
  77. require.Contains(t, normalized, "a.credentials = $7::jsonb")
  78. require.Contains(t, normalized, "a.proxy_id IS NOT DISTINCT FROM $8")
  79. require.Contains(t, normalized, "INSERT INTO scheduler_outbox")
  80. require.Len(t, exec.execArgs[0], 9)
  81. require.Equal(t, snapshot.CredentialsJSON, exec.execArgs[0][6])
  82. require.Equal(t, &proxyID, exec.execArgs[0][7])
  83. require.Equal(t, service.SchedulerOutboxEventAccountChanged, exec.execArgs[0][8])
  84. })
  85. }
  86. func TestAccountRepository_GrokCredentialCommitCarriesOutboxAcrossCallerCancellation(t *testing.T) {
  87. snapshot := service.GrokCredentialMutationSnapshot{CredentialsJSON: `{"access_token":"access","refresh_token":"refresh"}`}
  88. tests := []struct {
  89. name string
  90. mutate func(context.Context, *accountRepository) (bool, error)
  91. }{
  92. {
  93. name: "permanent",
  94. mutate: func(ctx context.Context, repo *accountRepository) (bool, error) {
  95. return repo.SetGrokCredentialErrorIfMatch(ctx, 42, snapshot, string(service.GrokCredentialReasonRevoked))
  96. },
  97. },
  98. {
  99. name: "transient",
  100. mutate: func(ctx context.Context, repo *accountRepository) (bool, error) {
  101. return repo.SetGrokCredentialTempUnschedulableIfMatch(ctx, 42, snapshot, time.Now().Add(time.Minute), string(service.GrokCredentialReasonRefreshTransient))
  102. },
  103. },
  104. }
  105. for _, tt := range tests {
  106. t.Run(tt.name, func(t *testing.T) {
  107. ctx, cancel := context.WithCancel(context.Background())
  108. exec := &recordingSQLExecutor{result: rowsAffectedResult(1), afterExec: cancel}
  109. repo := newAccountRepositoryWithSQL(nil, exec, nil)
  110. updated, err := tt.mutate(ctx, repo)
  111. require.NoError(t, err)
  112. require.True(t, updated)
  113. require.ErrorIs(t, ctx.Err(), context.Canceled)
  114. require.Len(t, exec.execQueries, 1, "state update and scheduler outbox must share one atomic SQL statement")
  115. require.Contains(t, normalizeSQLWhitespace(exec.execQueries[0]), "INSERT INTO scheduler_outbox")
  116. })
  117. }
  118. }
  119. func TestAccountRepository_SetGrokOAuthErrorIfCredentialsUnchanged_RequiresActiveExactCredentialMatch(t *testing.T) {
  120. exec := &recordingSQLExecutor{result: rowsAffectedResult(0)}
  121. repo := newAccountRepositoryWithSQL(nil, exec, nil)
  122. applied, err := repo.SetGrokOAuthErrorIfCredentialsUnchanged(
  123. context.Background(),
  124. 42,
  125. map[string]any{"access_token": "observed", "_token_version": int64(7)},
  126. "missing refresh token",
  127. )
  128. require.NoError(t, err)
  129. require.False(t, applied)
  130. require.Len(t, exec.execQueries, 1, "the account mutation and conditional outbox insert must be one statement")
  131. normalized := normalizeSQLWhitespace(exec.execQueries[0])
  132. require.Contains(t, normalized, "WITH updated AS")
  133. require.Contains(t, normalized, "INSERT INTO scheduler_outbox")
  134. require.Contains(t, normalized, "FROM updated")
  135. require.Contains(t, normalized, "platform = $4")
  136. require.Contains(t, normalized, "type = $5")
  137. require.Contains(t, normalized, "status = $6")
  138. require.Contains(t, normalized, "credentials = $7::jsonb")
  139. require.Contains(t, normalized, "NULLIF(BTRIM(a.credentials->>'refresh_token'), '') IS NULL")
  140. require.Len(t, exec.execArgs, 1)
  141. require.Equal(t, service.StatusActive, exec.execArgs[0][5])
  142. require.Contains(t, exec.execArgs[0][6], `"_token_version":7`)
  143. }
  144. func TestAccountRepository_SetGrokOAuthErrorIfCredentialsUnchanged_AppliedWritesOutbox(t *testing.T) {
  145. exec := &recordingSQLExecutor{result: rowsAffectedResult(1)}
  146. repo := newAccountRepositoryWithSQL(nil, exec, nil)
  147. applied, err := repo.SetGrokOAuthErrorIfCredentialsUnchanged(
  148. context.Background(),
  149. 42,
  150. map[string]any{"access_token": "observed"},
  151. "missing refresh token",
  152. )
  153. require.NoError(t, err)
  154. require.True(t, applied)
  155. require.Len(t, exec.execQueries, 1)
  156. normalized := normalizeSQLWhitespace(exec.execQueries[0])
  157. require.Contains(t, normalized, "WITH updated AS")
  158. require.Contains(t, normalized, "INSERT INTO scheduler_outbox")
  159. require.Contains(t, normalized, "SELECT $8, updated.id, NULL, NULL FROM updated")
  160. }
  161. func TestAccountRepository_SetGrokOAuthRefreshErrorIfCredentialsUnchanged_UsesAttemptCredentialsAndProxy(t *testing.T) {
  162. exec := &recordingSQLExecutor{result: rowsAffectedResult(0)}
  163. repo := newAccountRepositoryWithSQL(nil, exec, nil)
  164. proxyID := int64(17)
  165. applied, err := repo.SetGrokOAuthRefreshErrorIfCredentialsUnchanged(
  166. context.Background(),
  167. 42,
  168. map[string]any{"refresh_token": "attempted", "_token_version": int64(7)},
  169. &proxyID,
  170. "revoked",
  171. )
  172. require.NoError(t, err)
  173. require.False(t, applied)
  174. require.Len(t, exec.execQueries, 1)
  175. normalized := normalizeSQLWhitespace(exec.execQueries[0])
  176. require.Contains(t, normalized, "credentials = $7::jsonb")
  177. require.Contains(t, normalized, "proxy_id IS NOT DISTINCT FROM $8")
  178. require.NotContains(t, normalized, "credentials->>'refresh_token'",
  179. "background invalid_grant CAS must accept the attempted refresh token; only reconciliation requires it missing")
  180. require.Equal(t, &proxyID, exec.execArgs[0][7])
  181. require.Contains(t, normalized, "INSERT INTO scheduler_outbox")
  182. require.Len(t, exec.execArgs[0], 9)
  183. }
  184. func TestAccountRepository_SetGrokOAuthRefreshTempUnschedulableIfCredentialsUnchanged_UsesAttemptCredentialsAndProxy(t *testing.T) {
  185. exec := &recordingSQLExecutor{result: rowsAffectedResult(0)}
  186. repo := newAccountRepositoryWithSQL(nil, exec, nil)
  187. proxyID := int64(19)
  188. applied, err := repo.SetGrokOAuthRefreshTempUnschedulableIfCredentialsUnchanged(
  189. context.Background(),
  190. 42,
  191. map[string]any{"refresh_token": "attempted", "_token_version": int64(8)},
  192. &proxyID,
  193. time.Now().Add(10*time.Minute),
  194. "retry exhausted",
  195. )
  196. require.NoError(t, err)
  197. require.False(t, applied)
  198. require.Len(t, exec.execQueries, 1)
  199. normalized := normalizeSQLWhitespace(exec.execQueries[0])
  200. require.Contains(t, normalized, "credentials = $7::jsonb")
  201. require.Contains(t, normalized, "proxy_id IS NOT DISTINCT FROM $8")
  202. require.Contains(t, normalized, "a.temp_unschedulable_until IS NULL OR a.temp_unschedulable_until < $1")
  203. require.Len(t, exec.execArgs[0], 9)
  204. require.Equal(t, &proxyID, exec.execArgs[0][7])
  205. require.Contains(t, normalized, "INSERT INTO scheduler_outbox")
  206. }
  207. func TestAccountRepository_UpdateGrokOAuthCredentialsIfUnchanged_UsesExactAttemptStateAndAtomicOutbox(t *testing.T) {
  208. exec := &recordingSQLExecutor{result: rowsAffectedResult(1)}
  209. repo := newAccountRepositoryWithSQL(nil, exec, nil)
  210. proxyID := int64(29)
  211. applied, err := repo.UpdateGrokOAuthCredentialsIfUnchanged(
  212. context.Background(),
  213. 42,
  214. map[string]any{"refresh_token": "attempted", "_token_version": int64(9)},
  215. &proxyID,
  216. map[string]any{"refresh_token": "rotated", "_token_version": int64(10)},
  217. )
  218. require.NoError(t, err)
  219. require.True(t, applied)
  220. require.Len(t, exec.execQueries, 1)
  221. normalized := normalizeSQLWhitespace(exec.execQueries[0])
  222. require.Contains(t, normalized, "WITH updated AS")
  223. require.Contains(t, normalized, "credentials = $1::jsonb")
  224. require.Contains(t, normalized, "credentials = $5::jsonb")
  225. require.Contains(t, normalized, "proxy_id IS NOT DISTINCT FROM $6")
  226. require.Contains(t, normalized, "INSERT INTO scheduler_outbox")
  227. require.Len(t, exec.execArgs[0], 7)
  228. require.Equal(t, &proxyID, exec.execArgs[0][5])
  229. }
  230. func TestAccountRepository_ListOAuthRefreshCandidatePage_SQLFilter(t *testing.T) {
  231. db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
  232. require.NoError(t, err)
  233. defer func() { _ = db.Close() }()
  234. var capturedSQL string
  235. var capturedArgs []any
  236. mock.ExpectQuery("SELECT id").
  237. WillReturnRows(sqlmock.NewRows([]string{"id"})).
  238. WillDelayFor(0)
  239. repo := newAccountRepositoryWithSQL(nil, captureQuerySQL{db: db, captured: &capturedSQL, args: &capturedArgs}, nil)
  240. page, err := repo.ListOAuthRefreshCandidatePage(context.Background(), service.OAuthRefreshPageOptions{
  241. Platforms: []string{service.PlatformAnthropic, service.PlatformOpenAI, service.PlatformGemini, service.PlatformAntigravity, service.PlatformGrok},
  242. AfterID: 100,
  243. Limit: 200,
  244. ActiveOnly: true,
  245. IncludeSetupToken: true,
  246. RequireRefreshToken: true,
  247. ExcludeRetryCooldown: true,
  248. })
  249. require.NoError(t, err)
  250. require.Empty(t, page.Accounts)
  251. normalized := normalizeSQLWhitespace(capturedSQL)
  252. require.Contains(t, normalized, "deleted_at IS NULL")
  253. require.Contains(t, normalized, "schedulable = TRUE",
  254. "permanently unschedulable accounts must not remain OAuth refresh candidates")
  255. require.Contains(t, normalized, "status = 'active'")
  256. // setup-token 的 access_token 同为 8h 短期令牌,必须与 oauth 一起纳入后台刷新候选
  257. require.Contains(t, normalized, "type IN ('oauth', 'setup-token')")
  258. require.Contains(t, normalized, "platform = ANY($1)")
  259. require.NotContains(t, normalized, "platform IN ('anthropic'",
  260. "candidate platforms must come from the refresher registry instead of a second hard-coded list")
  261. require.Contains(t, normalized, "credentials ? 'refresh_token'")
  262. require.Contains(t, normalized, "btrim(credentials->>'refresh_token') <> ''")
  263. require.Contains(t, normalized, "temp_unschedulable_until > NOW()")
  264. require.Contains(t, normalized, "temp_unschedulable_reason LIKE 'token refresh retry exhausted:%'")
  265. require.Contains(t, normalized, "IS NOT TRUE",
  266. "must use IS NOT TRUE so accounts with NULL temp_unschedulable_until are not silently excluded by PG 3-valued logic")
  267. require.NotContains(t, normalized, "AND NOT (",
  268. "plain NOT (...) excludes NULL temp_unschedulable_until rows (the common healthy case)")
  269. require.Contains(t, normalized, "id > $2")
  270. require.Contains(t, normalized, "ORDER BY id ASC")
  271. require.Contains(t, normalized, "LIMIT $3")
  272. require.NotContains(t, normalized, "credentials->>'expires_at'")
  273. require.Len(t, capturedArgs, 3)
  274. require.Equal(t, int64(100), capturedArgs[1])
  275. require.Equal(t, 200, capturedArgs[2])
  276. valuer, ok := capturedArgs[0].(interface{ Value() (driver.Value, error) })
  277. require.True(t, ok)
  278. platforms, err := valuer.Value()
  279. require.NoError(t, err)
  280. require.Contains(t, platforms, service.PlatformGrok)
  281. require.NoError(t, mock.ExpectationsWereMet())
  282. }
  283. func TestAccountRepository_ListOAuthRefreshCandidatePage_ReconciliationExcludesAPIKeys(t *testing.T) {
  284. db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
  285. require.NoError(t, err)
  286. defer func() { _ = db.Close() }()
  287. var capturedSQL string
  288. mock.ExpectQuery("SELECT id").WillReturnRows(sqlmock.NewRows([]string{"id"}))
  289. repo := newAccountRepositoryWithSQL(nil, captureQuerySQL{db: db, captured: &capturedSQL}, nil)
  290. page, err := repo.ListOAuthRefreshCandidatePage(context.Background(), service.OAuthRefreshPageOptions{
  291. Platforms: []string{service.PlatformGrok},
  292. AfterID: 0,
  293. Limit: 50,
  294. })
  295. require.NoError(t, err)
  296. require.Empty(t, page.Accounts)
  297. normalized := normalizeSQLWhitespace(capturedSQL)
  298. require.Contains(t, normalized, "type = 'oauth'")
  299. require.NotContains(t, normalized, "type IN ('oauth', 'setup-token')")
  300. require.NotContains(t, normalized, "type = 'api-key'")
  301. require.NotContains(t, normalized, "credentials ? 'refresh_token'",
  302. "reconciliation must be able to find structurally invalid OAuth rows")
  303. require.Contains(t, normalized, "ORDER BY id ASC")
  304. require.NoError(t, mock.ExpectationsWereMet())
  305. }
  306. type captureQuerySQL struct {
  307. db *sql.DB
  308. captured *string
  309. args *[]any
  310. }
  311. func (c captureQuerySQL) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) {
  312. return c.db.ExecContext(ctx, query, args...)
  313. }
  314. func (c captureQuerySQL) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
  315. if c.captured != nil {
  316. *c.captured = query
  317. }
  318. if c.args != nil {
  319. *c.args = append([]any(nil), args...)
  320. }
  321. return c.db.QueryContext(ctx, query, args...)
  322. }
  323. func normalizeSQLWhitespace(sql string) string {
  324. return strings.Join(regexp.MustCompile(`\s+`).Split(strings.TrimSpace(sql), -1), " ")
  325. }
  326. type rowsAffectedResult int64
  327. func (r rowsAffectedResult) LastInsertId() (int64, error) { return 0, nil }
  328. func (r rowsAffectedResult) RowsAffected() (int64, error) { return int64(r), nil }
  329. type recordingSQLExecutor struct {
  330. result sql.Result
  331. err error
  332. afterExec func()
  333. execQueries []string
  334. execArgs [][]any
  335. }
  336. func (e *recordingSQLExecutor) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) {
  337. e.execQueries = append(e.execQueries, query)
  338. e.execArgs = append(e.execArgs, append([]any(nil), args...))
  339. if e.err != nil {
  340. return nil, e.err
  341. }
  342. if e.afterExec != nil {
  343. e.afterExec()
  344. }
  345. return e.result, nil
  346. }
  347. func (e *recordingSQLExecutor) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
  348. return nil, sql.ErrNoRows
  349. }