admin_account_upstream_billing_probe_test.go 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751
  1. package service
  2. import (
  3. "context"
  4. "net/http"
  5. "testing"
  6. "github.com/Wei-Shaw/sub2api/internal/pkg/xai"
  7. "github.com/stretchr/testify/require"
  8. )
  9. type upstreamBillingProbeAdminRepo struct {
  10. *upstreamBillingProbeAccountRepo
  11. }
  12. func (r *upstreamBillingProbeAdminRepo) ListShadowsByParent(context.Context, int64) ([]*Account, error) {
  13. return nil, nil
  14. }
  15. type accountBillingSettingsAdminRepo struct {
  16. *upstreamBillingProbeAccountRepo
  17. concurrentRate *float64
  18. lastExplicitRate *float64
  19. updateCalls int
  20. }
  21. func (r *accountBillingSettingsAdminRepo) UpdateWithAccountBillingSettings(
  22. _ context.Context,
  23. account *Account,
  24. probeEnabled *bool,
  25. rateSyncEnabled *bool,
  26. rateMultiplier *float64,
  27. ) error {
  28. r.mu.Lock()
  29. defer r.mu.Unlock()
  30. current := r.accounts[account.ID]
  31. if current == nil {
  32. return ErrAccountNotFound
  33. }
  34. updated := *account
  35. updated.Credentials = mergeMap(nil, account.Credentials)
  36. updated.Extra = mergeMap(nil, account.Extra)
  37. if updated.Extra == nil {
  38. updated.Extra = make(map[string]any)
  39. }
  40. if probeEnabled != nil {
  41. updated.Extra[UpstreamBillingProbeEnabledExtraKey] = *probeEnabled
  42. }
  43. if rateSyncEnabled != nil {
  44. updated.Extra[UpstreamBillingRateSyncEnabledExtraKey] = *rateSyncEnabled
  45. }
  46. switch {
  47. case rateMultiplier != nil:
  48. value := *rateMultiplier
  49. updated.RateMultiplier = &value
  50. r.lastExplicitRate = &value
  51. case r.concurrentRate != nil:
  52. value := *r.concurrentRate
  53. updated.RateMultiplier = &value
  54. r.lastExplicitRate = nil
  55. default:
  56. updated.RateMultiplier = cloneAccountValuePointer(current.RateMultiplier)
  57. r.lastExplicitRate = nil
  58. }
  59. r.accounts[account.ID] = &updated
  60. r.updateCalls++
  61. return nil
  62. }
  63. func TestUpdateAccountRoutesRateIntentThroughAtomicBillingUpdater(t *testing.T) {
  64. accountID := int64(109)
  65. initialRate := 0.1
  66. concurrentRate := 0.2
  67. repo := &accountBillingSettingsAdminRepo{
  68. upstreamBillingProbeAccountRepo: &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{
  69. accountID: {
  70. ID: accountID,
  71. Name: "before",
  72. Platform: PlatformOpenAI,
  73. Type: AccountTypeAPIKey,
  74. Status: StatusActive,
  75. RateMultiplier: &initialRate,
  76. Extra: map[string]any{
  77. UpstreamBillingProbeEnabledExtraKey: true,
  78. UpstreamBillingRateSyncEnabledExtraKey: true,
  79. },
  80. },
  81. }},
  82. concurrentRate: &concurrentRate,
  83. }
  84. svc := &adminServiceImpl{accountRepo: repo}
  85. updated, err := svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{Name: "after"})
  86. require.NoError(t, err)
  87. require.Equal(t, 1, repo.updateCalls)
  88. require.Nil(t, repo.lastExplicitRate)
  89. require.Equal(t, concurrentRate, *updated.RateMultiplier)
  90. // 手工倍率只有在同步不再开启时才被接受,所以同一请求先关闭同步再设值
  91. // (同步仍开启时的手工倍率由 TestUpdateAccountRejectsManualRateWhileRateSyncEnabled 覆盖)。
  92. zero := 0.0
  93. syncDisabled := false
  94. updated, err = svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{
  95. RateSyncEnabled: &syncDisabled,
  96. RateMultiplier: &zero,
  97. })
  98. require.NoError(t, err)
  99. require.Equal(t, 2, repo.updateCalls)
  100. require.NotNil(t, repo.lastExplicitRate)
  101. require.Zero(t, *repo.lastExplicitRate)
  102. require.Zero(t, *updated.RateMultiplier)
  103. }
  104. func TestCreateAccountDropsManagedUpstreamBillingProbeState(t *testing.T) {
  105. repo := &upstreamBillingProbeAccountRepo{}
  106. svc := &adminServiceImpl{accountRepo: repo}
  107. created, err := svc.CreateAccount(context.Background(), &CreateAccountInput{
  108. Name: "upstream",
  109. Platform: PlatformOpenAI,
  110. Type: AccountTypeAPIKey,
  111. Credentials: map[string]any{"api_key": "sk-test"},
  112. SkipDefaultGroupBind: true,
  113. Extra: map[string]any{
  114. UpstreamBillingProbeEnabledExtraKey: true,
  115. UpstreamBillingRateSyncEnabledExtraKey: true,
  116. UpstreamBillingProbeExtraKey: map[string]any{"status": "ok"},
  117. },
  118. })
  119. require.NoError(t, err)
  120. require.NotContains(t, created.Extra, UpstreamBillingProbeEnabledExtraKey)
  121. require.NotContains(t, created.Extra, UpstreamBillingRateSyncEnabledExtraKey)
  122. require.NotContains(t, created.Extra, UpstreamBillingProbeExtraKey)
  123. }
  124. func TestCreateAccountAcceptsDedicatedUpstreamBillingProbeSetting(t *testing.T) {
  125. enabled := true
  126. repo := &upstreamBillingProbeAccountRepo{}
  127. created, err := (&adminServiceImpl{accountRepo: repo}).CreateAccount(context.Background(), &CreateAccountInput{
  128. Name: "upstream",
  129. Platform: PlatformOpenAI,
  130. Type: AccountTypeAPIKey,
  131. Credentials: map[string]any{"api_key": "sk-test"},
  132. ProbeEnabled: &enabled,
  133. SkipDefaultGroupBind: true,
  134. })
  135. require.NoError(t, err)
  136. require.Equal(t, true, created.Extra[UpstreamBillingProbeEnabledExtraKey])
  137. _, err = (&adminServiceImpl{accountRepo: repo}).CreateAccount(context.Background(), &CreateAccountInput{
  138. Name: "oauth",
  139. Platform: PlatformOpenAI,
  140. Type: AccountTypeOAuth,
  141. Credentials: map[string]any{"access_token": "token"},
  142. ProbeEnabled: &enabled,
  143. SkipDefaultGroupBind: true,
  144. })
  145. require.ErrorIs(t, err, ErrUpstreamBillingProbeAccountInvalid)
  146. }
  147. func TestUpdateAccountPreservesManagedUpstreamBillingProbeStateForUnrelatedEdit(t *testing.T) {
  148. accountID := int64(110)
  149. repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{
  150. accountID: {
  151. ID: accountID,
  152. Platform: PlatformOpenAI,
  153. Type: AccountTypeAPIKey,
  154. Status: StatusActive,
  155. Extra: map[string]any{
  156. UpstreamBillingProbeEnabledExtraKey: true,
  157. UpstreamBillingRateSyncEnabledExtraKey: true,
  158. UpstreamBillingProbeExtraKey: map[string]any{"status": "ok"},
  159. },
  160. },
  161. }}
  162. svc := &adminServiceImpl{accountRepo: repo}
  163. updated, err := svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{
  164. Extra: map[string]any{"custom": "value"},
  165. })
  166. require.NoError(t, err)
  167. require.Equal(t, true, updated.Extra[UpstreamBillingProbeEnabledExtraKey])
  168. require.Equal(t, true, updated.Extra[UpstreamBillingRateSyncEnabledExtraKey])
  169. require.Contains(t, updated.Extra, UpstreamBillingProbeExtraKey)
  170. require.Equal(t, "value", updated.Extra["custom"])
  171. }
  172. func TestUpdateAccountPreservesGrokBillingSnapshotForUnrelatedEdit(t *testing.T) {
  173. accountID := int64(112)
  174. billing := &xai.BillingSummary{
  175. StatusCode: http.StatusForbidden,
  176. WeeklyStatusCode: http.StatusForbidden,
  177. }
  178. repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{
  179. accountID: {
  180. ID: accountID,
  181. Platform: PlatformGrok,
  182. Type: AccountTypeOAuth,
  183. Status: StatusActive,
  184. Extra: map[string]any{grokBillingExtraKey: billing},
  185. },
  186. }}
  187. updated, err := (&adminServiceImpl{accountRepo: repo}).UpdateAccount(context.Background(), accountID, &UpdateAccountInput{
  188. Extra: map[string]any{"custom": "value"},
  189. })
  190. require.NoError(t, err)
  191. require.Equal(t, billing, updated.Extra[grokBillingExtraKey])
  192. require.Equal(t, "value", updated.Extra["custom"])
  193. eligible, reason := updated.GrokMediaGenerationEligibility()
  194. require.False(t, eligible)
  195. require.Equal(t, "billing_forbidden", reason)
  196. }
  197. func TestUpdateAccountPreservesProbeSnapshotWhenIdentityValuesAreUnchanged(t *testing.T) {
  198. accountID := int64(119)
  199. repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{
  200. accountID: {
  201. ID: accountID,
  202. Platform: PlatformOpenAI,
  203. Type: AccountTypeAPIKey,
  204. Status: StatusActive,
  205. Credentials: map[string]any{
  206. "api_key": "sk-existing",
  207. "base_url": "https://upstream.example",
  208. credKeyHeaderOverrideEnabled: true,
  209. credKeyHeaderOverrides: map[string]any{"x-route": "stable"},
  210. },
  211. Extra: map[string]any{
  212. UpstreamBillingProbeEnabledExtraKey: true,
  213. UpstreamBillingProbeExtraKey: map[string]any{"status": "ok"},
  214. },
  215. },
  216. }}
  217. updated, err := (&adminServiceImpl{accountRepo: repo}).UpdateAccount(context.Background(), accountID, &UpdateAccountInput{
  218. Credentials: map[string]any{
  219. "base_url": "https://upstream.example",
  220. credKeyHeaderOverrideEnabled: true,
  221. credKeyHeaderOverrides: map[string]any{"x-route": "stable"},
  222. },
  223. })
  224. require.NoError(t, err)
  225. require.Contains(t, updated.Extra, UpstreamBillingProbeExtraKey)
  226. }
  227. func TestUpdateAccountInvalidatesProbeSnapshotWhenUpstreamIdentityChanges(t *testing.T) {
  228. tests := []struct {
  229. name string
  230. input *UpdateAccountInput
  231. wantEnabled bool
  232. }{
  233. {
  234. name: "api key",
  235. input: &UpdateAccountInput{Credentials: map[string]any{"api_key": "sk-new"}},
  236. wantEnabled: true,
  237. },
  238. {
  239. name: "base url",
  240. input: &UpdateAccountInput{Credentials: map[string]any{"base_url": "https://new.example"}},
  241. wantEnabled: true,
  242. },
  243. {
  244. name: "header override",
  245. input: &UpdateAccountInput{Credentials: map[string]any{
  246. credKeyHeaderOverrideEnabled: true,
  247. credKeyHeaderOverrides: map[string]any{"x-route": "new"},
  248. }},
  249. wantEnabled: true,
  250. },
  251. {
  252. name: "account type",
  253. input: &UpdateAccountInput{Type: AccountTypeOAuth},
  254. wantEnabled: false,
  255. },
  256. }
  257. for i, tt := range tests {
  258. t.Run(tt.name, func(t *testing.T) {
  259. accountID := int64(120 + i)
  260. repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{
  261. accountID: {
  262. ID: accountID,
  263. Platform: PlatformOpenAI,
  264. Type: AccountTypeAPIKey,
  265. Status: StatusActive,
  266. Credentials: map[string]any{
  267. "api_key": "sk-old",
  268. "base_url": "https://old.example",
  269. },
  270. Extra: map[string]any{
  271. UpstreamBillingProbeEnabledExtraKey: true,
  272. UpstreamBillingRateSyncEnabledExtraKey: true,
  273. UpstreamBillingProbeExtraKey: map[string]any{"status": "ok"},
  274. },
  275. },
  276. }}
  277. updated, err := (&adminServiceImpl{accountRepo: repo}).UpdateAccount(context.Background(), accountID, tt.input)
  278. require.NoError(t, err)
  279. require.NotContains(t, updated.Extra, UpstreamBillingProbeExtraKey)
  280. if tt.wantEnabled {
  281. require.Equal(t, true, updated.Extra[UpstreamBillingProbeEnabledExtraKey])
  282. } else {
  283. require.NotContains(t, updated.Extra, UpstreamBillingProbeEnabledExtraKey)
  284. require.NotContains(t, updated.Extra, UpstreamBillingRateSyncEnabledExtraKey)
  285. }
  286. })
  287. }
  288. }
  289. func TestUpdateAccountInvalidatesProbeSnapshotWhenProxyChanges(t *testing.T) {
  290. accountID := int64(140)
  291. oldProxyID := int64(7)
  292. newProxyID := int64(8)
  293. baseRepo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{
  294. accountID: {
  295. ID: accountID,
  296. Platform: PlatformOpenAI,
  297. Type: AccountTypeAPIKey,
  298. Status: StatusActive,
  299. Credentials: map[string]any{"api_key": "sk-test"},
  300. ProxyID: &oldProxyID,
  301. Extra: map[string]any{
  302. UpstreamBillingProbeEnabledExtraKey: true,
  303. UpstreamBillingProbeExtraKey: map[string]any{"status": "ok"},
  304. },
  305. },
  306. }}
  307. updated, err := (&adminServiceImpl{accountRepo: &upstreamBillingProbeAdminRepo{baseRepo}}).UpdateAccount(
  308. context.Background(),
  309. accountID,
  310. &UpdateAccountInput{ProxyID: &newProxyID},
  311. )
  312. require.NoError(t, err)
  313. require.Equal(t, newProxyID, *updated.ProxyID)
  314. require.NotContains(t, updated.Extra, UpstreamBillingProbeExtraKey)
  315. }
  316. func TestUpdateAccountPreservesProbeSnapshotWhenProxyIsUnchanged(t *testing.T) {
  317. accountID := int64(141)
  318. existingProxyID := int64(7)
  319. unchangedProxyID := int64(7)
  320. baseRepo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{
  321. accountID: {
  322. ID: accountID,
  323. Platform: PlatformOpenAI,
  324. Type: AccountTypeAPIKey,
  325. Status: StatusActive,
  326. Credentials: map[string]any{"api_key": "sk-test"},
  327. ProxyID: &existingProxyID,
  328. Extra: map[string]any{
  329. UpstreamBillingProbeEnabledExtraKey: true,
  330. UpstreamBillingProbeExtraKey: map[string]any{"status": "ok"},
  331. },
  332. },
  333. }}
  334. updated, err := (&adminServiceImpl{accountRepo: &upstreamBillingProbeAdminRepo{baseRepo}}).UpdateAccount(
  335. context.Background(),
  336. accountID,
  337. &UpdateAccountInput{ProxyID: &unchangedProxyID},
  338. )
  339. require.NoError(t, err)
  340. require.Contains(t, updated.Extra, UpstreamBillingProbeExtraKey)
  341. }
  342. func TestUpdateAccountAcceptsProbeEnabledAndRejectsInjectedSnapshot(t *testing.T) {
  343. accountID := int64(111)
  344. repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{
  345. accountID: {
  346. ID: accountID,
  347. Platform: PlatformOpenAI,
  348. Type: AccountTypeAPIKey,
  349. Status: StatusActive,
  350. Extra: map[string]any{},
  351. },
  352. }}
  353. svc := &adminServiceImpl{accountRepo: repo}
  354. updated, err := svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{
  355. Extra: map[string]any{
  356. UpstreamBillingProbeEnabledExtraKey: true,
  357. UpstreamBillingRateSyncEnabledExtraKey: true,
  358. UpstreamBillingProbeExtraKey: map[string]any{"status": "ok"},
  359. },
  360. })
  361. require.NoError(t, err)
  362. require.Equal(t, true, updated.Extra[UpstreamBillingProbeEnabledExtraKey])
  363. require.NotContains(t, updated.Extra, UpstreamBillingRateSyncEnabledExtraKey)
  364. require.NotContains(t, updated.Extra, UpstreamBillingProbeExtraKey)
  365. }
  366. func TestUpdateAccountRateSyncControlsProbeAndManualMode(t *testing.T) {
  367. accountID := int64(151)
  368. repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{
  369. accountID: {
  370. ID: accountID,
  371. Platform: PlatformGemini,
  372. Type: AccountTypeAPIKey,
  373. Status: StatusActive,
  374. Extra: map[string]any{},
  375. },
  376. }}
  377. svc := &adminServiceImpl{accountRepo: repo}
  378. syncEnabled := true
  379. updated, err := svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{
  380. RateSyncEnabled: &syncEnabled,
  381. })
  382. require.NoError(t, err)
  383. require.Equal(t, true, updated.Extra[UpstreamBillingProbeEnabledExtraKey])
  384. require.Equal(t, true, updated.Extra[UpstreamBillingRateSyncEnabledExtraKey])
  385. syncEnabled = false
  386. updated, err = svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{
  387. RateSyncEnabled: &syncEnabled,
  388. })
  389. require.NoError(t, err)
  390. require.Equal(t, true, updated.Extra[UpstreamBillingProbeEnabledExtraKey])
  391. require.Equal(t, false, updated.Extra[UpstreamBillingRateSyncEnabledExtraKey])
  392. }
  393. // 单账号编辑必须和批量路径语义一致:同步开启时倍率归上游所有,手工值会在下一次
  394. // 成功探测时被覆盖,因此直接拒绝而不是静默接受。
  395. func TestUpdateAccountRejectsManualRateWhileRateSyncEnabled(t *testing.T) {
  396. newRepo := func(accountID int64, extra map[string]any) *upstreamBillingProbeAccountRepo {
  397. initialRate := 0.25
  398. return &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{
  399. accountID: {
  400. ID: accountID,
  401. Platform: PlatformOpenAI,
  402. Type: AccountTypeAPIKey,
  403. Status: StatusActive,
  404. RateMultiplier: &initialRate,
  405. Extra: extra,
  406. },
  407. }}
  408. }
  409. manualRate := 3.5
  410. syncEnabled := map[string]any{
  411. UpstreamBillingProbeEnabledExtraKey: true,
  412. UpstreamBillingRateSyncEnabledExtraKey: true,
  413. }
  414. t.Run("sync enabled rejects manual rate", func(t *testing.T) {
  415. accountID := int64(153)
  416. repo := newRepo(accountID, mergeMap(nil, syncEnabled))
  417. _, err := (&adminServiceImpl{accountRepo: repo}).UpdateAccount(context.Background(), accountID, &UpdateAccountInput{
  418. RateMultiplier: &manualRate,
  419. })
  420. require.ErrorIs(t, err, ErrUpstreamBillingRateSyncConflict)
  421. require.Equal(t, 0.25, *repo.accounts[accountID].RateMultiplier)
  422. })
  423. t.Run("enabling sync in the same request rejects manual rate", func(t *testing.T) {
  424. accountID := int64(154)
  425. repo := newRepo(accountID, map[string]any{})
  426. enable := true
  427. _, err := (&adminServiceImpl{accountRepo: repo}).UpdateAccount(context.Background(), accountID, &UpdateAccountInput{
  428. RateSyncEnabled: &enable,
  429. RateMultiplier: &manualRate,
  430. })
  431. require.ErrorIs(t, err, ErrUpstreamBillingRateSyncConflict)
  432. require.Equal(t, 0.25, *repo.accounts[accountID].RateMultiplier)
  433. })
  434. // 用户显式收回所有权:同一请求关闭同步并改倍率必须放行。
  435. t.Run("disabling sync in the same request allows manual rate", func(t *testing.T) {
  436. accountID := int64(155)
  437. repo := newRepo(accountID, mergeMap(nil, syncEnabled))
  438. disable := false
  439. updated, err := (&adminServiceImpl{accountRepo: repo}).UpdateAccount(context.Background(), accountID, &UpdateAccountInput{
  440. RateSyncEnabled: &disable,
  441. RateMultiplier: &manualRate,
  442. })
  443. require.NoError(t, err)
  444. require.Equal(t, false, updated.Extra[UpstreamBillingRateSyncEnabledExtraKey])
  445. require.NotNil(t, updated.RateMultiplier)
  446. require.Equal(t, manualRate, *updated.RateMultiplier)
  447. })
  448. t.Run("sync disabled allows manual rate", func(t *testing.T) {
  449. accountID := int64(156)
  450. repo := newRepo(accountID, map[string]any{UpstreamBillingProbeEnabledExtraKey: true})
  451. updated, err := (&adminServiceImpl{accountRepo: repo}).UpdateAccount(context.Background(), accountID, &UpdateAccountInput{
  452. RateMultiplier: &manualRate,
  453. })
  454. require.NoError(t, err)
  455. require.NotNil(t, updated.RateMultiplier)
  456. require.Equal(t, manualRate, *updated.RateMultiplier)
  457. })
  458. }
  459. func TestUpdateAccountRejectsSyncWithExplicitlyDisabledProbe(t *testing.T) {
  460. accountID := int64(152)
  461. repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{
  462. accountID: {
  463. ID: accountID,
  464. Platform: PlatformAnthropic,
  465. Type: AccountTypeAPIKey,
  466. Status: StatusActive,
  467. },
  468. }}
  469. probeEnabled := false
  470. syncEnabled := true
  471. _, err := (&adminServiceImpl{accountRepo: repo}).UpdateAccount(context.Background(), accountID, &UpdateAccountInput{
  472. ProbeEnabled: &probeEnabled,
  473. RateSyncEnabled: &syncEnabled,
  474. })
  475. require.Error(t, err)
  476. require.Empty(t, repo.updates[accountID])
  477. }
  478. func TestUpdateAccountExplicitProbeDisableUsesDedicatedExtraUpdate(t *testing.T) {
  479. accountID := int64(113)
  480. repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{
  481. accountID: {
  482. ID: accountID,
  483. Platform: PlatformOpenAI,
  484. Type: AccountTypeAPIKey,
  485. Status: StatusActive,
  486. Extra: map[string]any{
  487. UpstreamBillingProbeEnabledExtraKey: true,
  488. UpstreamBillingProbeExtraKey: map[string]any{"status": "ok"},
  489. },
  490. },
  491. }}
  492. _, err := (&adminServiceImpl{accountRepo: repo}).UpdateAccount(context.Background(), accountID, &UpdateAccountInput{
  493. Extra: map[string]any{UpstreamBillingProbeEnabledExtraKey: false},
  494. })
  495. require.NoError(t, err)
  496. require.Len(t, repo.updates[accountID], 1)
  497. require.Equal(t, false, repo.updates[accountID][0][UpstreamBillingProbeEnabledExtraKey])
  498. require.Equal(t, false, repo.updates[accountID][0][UpstreamBillingRateSyncEnabledExtraKey])
  499. }
  500. func TestUpdateAccountExplicitUnchangedProbeEnabledStillUsesDedicatedExtraUpdate(t *testing.T) {
  501. accountID := int64(114)
  502. repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{
  503. accountID: {
  504. ID: accountID,
  505. Platform: PlatformOpenAI,
  506. Type: AccountTypeAPIKey,
  507. Status: StatusActive,
  508. Extra: map[string]any{UpstreamBillingProbeEnabledExtraKey: true},
  509. },
  510. }}
  511. _, err := (&adminServiceImpl{accountRepo: repo}).UpdateAccount(context.Background(), accountID, &UpdateAccountInput{
  512. Extra: map[string]any{UpstreamBillingProbeEnabledExtraKey: true},
  513. })
  514. require.NoError(t, err)
  515. require.Len(t, repo.updates[accountID], 1)
  516. require.Equal(t, true, repo.updates[accountID][0][UpstreamBillingProbeEnabledExtraKey])
  517. }
  518. func TestUpdateAccountRejectsInvalidProbeEnabled(t *testing.T) {
  519. accountID := int64(112)
  520. repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{
  521. accountID: {
  522. ID: accountID,
  523. Platform: PlatformOpenAI,
  524. Type: AccountTypeAPIKey,
  525. Status: StatusActive,
  526. Extra: map[string]any{},
  527. },
  528. }}
  529. svc := &adminServiceImpl{accountRepo: repo}
  530. _, err := svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{
  531. Extra: map[string]any{UpstreamBillingProbeEnabledExtraKey: "true"},
  532. })
  533. require.Error(t, err)
  534. }
  535. func TestUpdateAccountExtraDropsManagedBillingProbeFields(t *testing.T) {
  536. accountID := int64(153)
  537. repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{
  538. accountID: {ID: accountID, Platform: PlatformOpenAI, Type: AccountTypeAPIKey},
  539. }}
  540. err := (&adminServiceImpl{accountRepo: repo}).UpdateAccountExtra(context.Background(), accountID, map[string]any{
  541. "custom": "value",
  542. UpstreamBillingProbeEnabledExtraKey: true,
  543. UpstreamBillingRateSyncEnabledExtraKey: true,
  544. UpstreamBillingProbeExtraKey: map[string]any{"status": "ok"},
  545. })
  546. require.NoError(t, err)
  547. require.Equal(t, "value", repo.accounts[accountID].Extra["custom"])
  548. require.NotContains(t, repo.accounts[accountID].Extra, UpstreamBillingProbeEnabledExtraKey)
  549. require.NotContains(t, repo.accounts[accountID].Extra, UpstreamBillingRateSyncEnabledExtraKey)
  550. require.NotContains(t, repo.accounts[accountID].Extra, UpstreamBillingProbeExtraKey)
  551. }
  552. func TestBulkUpdateAccountsDropsManagedUpstreamBillingProbeState(t *testing.T) {
  553. repo := &upstreamBillingProbeAccountRepo{}
  554. svc := &adminServiceImpl{accountRepo: repo}
  555. input := &BulkUpdateAccountsInput{
  556. AccountIDs: []int64{1},
  557. Extra: map[string]any{
  558. "custom": "value",
  559. UpstreamBillingProbeEnabledExtraKey: true,
  560. UpstreamBillingRateSyncEnabledExtraKey: true,
  561. UpstreamBillingProbeExtraKey: map[string]any{"status": "ok"},
  562. },
  563. }
  564. result, err := svc.BulkUpdateAccounts(context.Background(), input)
  565. require.NoError(t, err)
  566. require.Equal(t, 1, result.Success)
  567. require.Len(t, repo.bulkUpdates, 1)
  568. require.Equal(t, "value", repo.bulkUpdates[0].Extra["custom"])
  569. require.NotContains(t, repo.bulkUpdates[0].Extra, UpstreamBillingProbeEnabledExtraKey)
  570. require.NotContains(t, repo.bulkUpdates[0].Extra, UpstreamBillingRateSyncEnabledExtraKey)
  571. require.NotContains(t, repo.bulkUpdates[0].Extra, UpstreamBillingProbeExtraKey)
  572. }
  573. func TestBulkUpdateAccountsAcceptsDedicatedUpstreamBillingProbeSetting(t *testing.T) {
  574. for _, enabled := range []bool{true, false} {
  575. t.Run(map[bool]string{true: "enable", false: "disable"}[enabled], func(t *testing.T) {
  576. repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{
  577. 1: {ID: 1, Platform: PlatformOpenAI, Type: AccountTypeAPIKey},
  578. 2: {ID: 2, Platform: PlatformOpenAI, Type: AccountTypeAPIKey},
  579. }}
  580. result, err := (&adminServiceImpl{accountRepo: repo}).BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{
  581. AccountIDs: []int64{1, 2},
  582. ProbeEnabled: &enabled,
  583. })
  584. require.NoError(t, err)
  585. require.Equal(t, 2, result.Success)
  586. require.Len(t, repo.bulkUpdates, 1)
  587. require.Equal(t, enabled, repo.bulkUpdates[0].Extra[UpstreamBillingProbeEnabledExtraKey])
  588. if !enabled {
  589. require.Equal(t, false, repo.bulkUpdates[0].Extra[UpstreamBillingRateSyncEnabledExtraKey])
  590. }
  591. require.NotNil(t, repo.bulkUpdates[0].ProbeEnabled)
  592. require.Equal(t, enabled, *repo.bulkUpdates[0].ProbeEnabled)
  593. })
  594. }
  595. }
  596. func TestBulkUpdateAccountsRejectsProbeSettingForIneligibleTargetBeforeWrite(t *testing.T) {
  597. for _, enabled := range []bool{true, false} {
  598. t.Run(map[bool]string{true: "enable", false: "disable"}[enabled], func(t *testing.T) {
  599. repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{
  600. 1: {ID: 1, Platform: PlatformOpenAI, Type: AccountTypeAPIKey},
  601. 2: {ID: 2, Platform: PlatformOpenAI, Type: AccountTypeOAuth},
  602. }}
  603. _, err := (&adminServiceImpl{accountRepo: repo}).BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{
  604. AccountIDs: []int64{1, 2},
  605. ProbeEnabled: &enabled,
  606. })
  607. require.ErrorIs(t, err, ErrUpstreamBillingProbeAccountInvalid)
  608. require.Empty(t, repo.bulkUpdates)
  609. })
  610. }
  611. }
  612. func TestBulkUpdateAccountsRejectsProbeSettingWhenTargetIsMissing(t *testing.T) {
  613. enabled := true
  614. repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{
  615. 1: {ID: 1, Platform: PlatformOpenAI, Type: AccountTypeAPIKey},
  616. }}
  617. _, err := (&adminServiceImpl{accountRepo: repo}).BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{
  618. AccountIDs: []int64{1, 2},
  619. ProbeEnabled: &enabled,
  620. })
  621. require.ErrorIs(t, err, ErrAccountNotFound)
  622. require.Empty(t, repo.bulkUpdates)
  623. }
  624. func TestBulkUpdateAccountsInvalidatesProbeSnapshotForIdentityCredentials(t *testing.T) {
  625. repo := &upstreamBillingProbeAccountRepo{}
  626. input := &BulkUpdateAccountsInput{
  627. AccountIDs: []int64{1},
  628. Credentials: map[string]any{"api_key": "sk-new"},
  629. }
  630. result, err := (&adminServiceImpl{accountRepo: repo}).BulkUpdateAccounts(context.Background(), input)
  631. require.NoError(t, err)
  632. require.Equal(t, 1, result.Success)
  633. require.Len(t, repo.bulkUpdates, 1)
  634. require.Contains(t, repo.bulkUpdates[0].Extra, UpstreamBillingProbeExtraKey)
  635. require.Nil(t, repo.bulkUpdates[0].Extra[UpstreamBillingProbeExtraKey])
  636. }
  637. func TestBulkUpdateAccountsInvalidatesProbeSnapshotForProxyUpdate(t *testing.T) {
  638. proxyID := int64(9)
  639. baseRepo := &upstreamBillingProbeAccountRepo{}
  640. input := &BulkUpdateAccountsInput{
  641. AccountIDs: []int64{1},
  642. ProxyID: &proxyID,
  643. }
  644. result, err := (&adminServiceImpl{accountRepo: &upstreamBillingProbeAdminRepo{baseRepo}}).BulkUpdateAccounts(context.Background(), input)
  645. require.NoError(t, err)
  646. require.Equal(t, 1, result.Success)
  647. require.Len(t, baseRepo.bulkUpdates, 1)
  648. require.Contains(t, baseRepo.bulkUpdates[0].Extra, UpstreamBillingProbeExtraKey)
  649. require.Nil(t, baseRepo.bulkUpdates[0].Extra[UpstreamBillingProbeExtraKey])
  650. }
  651. func TestBulkUpdateAccountsKeepsProbeSnapshotForUnrelatedCredentials(t *testing.T) {
  652. repo := &upstreamBillingProbeAccountRepo{}
  653. input := &BulkUpdateAccountsInput{
  654. AccountIDs: []int64{1},
  655. Credentials: map[string]any{"model_mapping": map[string]any{"gpt-old": "gpt-new"}},
  656. }
  657. _, err := (&adminServiceImpl{accountRepo: repo}).BulkUpdateAccounts(context.Background(), input)
  658. require.NoError(t, err)
  659. require.Len(t, repo.bulkUpdates, 1)
  660. require.NotContains(t, repo.bulkUpdates[0].Extra, UpstreamBillingProbeExtraKey)
  661. }